""" Административная панель для домашних заданий. """ from django import forms from django.contrib import admin from django.utils.html import format_html from django.urls import reverse from .models import Homework, HomeworkSubmission, HomeworkFile, HomeworkAssignmentFile, HomeworkAIAgent class HomeworkAssignmentFileForm(forms.ModelForm): """Форма файла задания: имя и размер подставляются из загруженного файла.""" class Meta: model = HomeworkAssignmentFile fields = ['file', 'filename', 'file_size'] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['file'].required = False def clean_file(self): f = self.cleaned_data.get('file') if f: self.instance.filename = getattr(f, 'name', '') or str(f) self.instance.file_size = getattr(f, 'size', 0) or 0 return f def clean(self): super().clean() f = self.cleaned_data.get('file') if f: self.cleaned_data['filename'] = getattr(f, 'name', '') or str(f) self.cleaned_data['file_size'] = getattr(f, 'size', 0) or 0 return self.cleaned_data class HomeworkAssignmentFileInline(admin.TabularInline): """Инлайн: файлы задания (прямая связь Homework → HomeworkAssignmentFile).""" model = HomeworkAssignmentFile form = HomeworkAssignmentFileForm fk_name = 'homework' extra = 3 max_num = 20 verbose_name = 'Файл задания' verbose_name_plural = 'Файлы задания' fields = ['file', 'filename', 'file_size'] readonly_fields = ['filename', 'file_size'] def save_formset(self, request, form, formset, change): parent = form.instance instances = formset.save(commit=False) for instance in instances: if not instance.file: continue instance.homework = parent if not instance.pk: instance.uploaded_by = request.user instance.filename = getattr(instance.file, 'name', '') or instance.filename or '' instance.file_size = getattr(instance.file, 'size', 0) or instance.file_size or 0 instance.save() for obj in formset.deleted_objects: obj.delete() @admin.register(Homework) class HomeworkAdmin(admin.ModelAdmin): """Админ интерфейс для ДЗ.""" list_display = [ 'title', 'mentor_link', 'lesson_link', 'status_badge', 'deadline', 'submissions_info', 'average_score', 'published_at', 'created_at' ] list_filter = [ 'status', 'created_at', 'deadline', 'published_at', 'allow_late_submission', 'auto_check_enabled', 'ai_check_enabled' ] search_fields = [ 'title', 'description', 'mentor__email', 'mentor__first_name', 'mentor__last_name' ] readonly_fields = [ 'total_submissions', 'checked_submissions', 'average_score', 'created_at', 'updated_at', 'published_at' ] filter_horizontal = ['assigned_to'] inlines = [HomeworkAssignmentFileInline] fieldsets = ( ('Основная информация', { 'fields': ( 'title', 'description', 'mentor', 'lesson' ) }), ('Назначение', { 'fields': ( 'assigned_to', ) }), ('Файлы и ссылки', { 'fields': ('attachment_url',), 'description': 'Файлы задания добавляйте в блоке «Файлы задания» ниже. Ссылка на материал — опционально.', }), ('Дедлайн и баллы', { 'fields': ( 'deadline', 'max_score', 'passing_score' ) }), ('Настройки', { 'fields': ( 'allow_late_submission', 'auto_check_enabled', 'ai_check_enabled', 'requires_file', 'allowed_file_types', 'max_file_size' ) }), ('Статус и статистика', { 'fields': ( 'status', 'total_submissions', 'checked_submissions', 'average_score' ) }), ('Временные метки', { 'fields': ( 'created_at', 'updated_at', 'published_at' ) }) ) actions = ['publish_homework', 'archive_homework'] def mentor_link(self, obj): """Ссылка на ментора.""" url = reverse('admin:users_user_change', args=[obj.mentor.id]) return format_html('{}', url, obj.mentor.get_full_name()) mentor_link.short_description = 'Ментор' def lesson_link(self, obj): """Ссылка на занятие.""" if obj.lesson: url = reverse('admin:schedule_lesson_change', args=[obj.lesson.id]) return format_html('{}', url, obj.lesson.title) return '-' lesson_link.short_description = 'Занятие' def status_badge(self, obj): """Бейдж статуса.""" colors = { 'draft': '#6c757d', 'published': '#28a745', 'archived': '#ffc107' } return format_html( '{}', colors.get(obj.status, '#000'), obj.get_status_display() ) status_badge.short_description = 'Статус' def submissions_info(self, obj): """Информация о решениях.""" return f"{obj.checked_submissions}/{obj.total_submissions}" submissions_info.short_description = 'Проверено/Всего' @admin.action(description='Опубликовать задания') def publish_homework(self, request, queryset): """Опубликовать задания.""" for homework in queryset: homework.publish() @admin.action(description='Архивировать задания') def archive_homework(self, request, queryset): """Архивировать задания.""" queryset.update(status='archived') @admin.register(HomeworkSubmission) class HomeworkSubmissionAdmin(admin.ModelAdmin): """Админ интерфейс для решений ДЗ.""" list_display = [ 'id', 'homework_link', 'student_link', 'status_badge', 'score_display', 'passed', 'is_late', 'attempt_number', 'submitted_at', 'checked_at' ] list_filter = [ 'status', 'passed', 'is_late', 'submitted_at', 'checked_at' ] search_fields = [ 'homework__title', 'student__email', 'student__first_name', 'student__last_name', 'content' ] readonly_fields = [ 'student', 'submitted_at', 'updated_at', 'checked_at', 'ai_checked_at', 'attempt_number', 'is_late' ] fieldsets = ( ('Основная информация', { 'fields': ( 'homework', 'student', 'attempt_number' ) }), ('Решение', { 'fields': ( 'content', 'attachment', 'attachment_url' ) }), ('Проверка', { 'fields': ( 'status', 'score', 'passed', 'feedback', 'checked_by', 'checked_at' ) }), ('AI проверка', { 'fields': ( 'ai_score', 'ai_feedback', 'ai_checked_at' ), 'classes': ('collapse',) }), ('Дополнительно', { 'fields': ( 'is_late', 'submitted_at', 'updated_at' ) }) ) def homework_link(self, obj): """Ссылка на ДЗ.""" url = reverse('admin:homework_homework_change', args=[obj.homework.id]) return format_html('{}', url, obj.homework.title) homework_link.short_description = 'Задание' def student_link(self, obj): """Ссылка на студента.""" url = reverse('admin:users_user_change', args=[obj.student.id]) return format_html('{}', url, obj.student.get_full_name()) student_link.short_description = 'Студент' def status_badge(self, obj): """Бейдж статуса.""" colors = { 'pending': '#ffc107', 'checking': '#17a2b8', 'graded': '#28a745', 'returned': '#dc3545' } return format_html( '{}', colors.get(obj.status, '#000'), obj.get_status_display() ) status_badge.short_description = 'Статус' def score_display(self, obj): """Отображение балла.""" if obj.score is not None: return f"{obj.score}/{obj.homework.max_score}" return '-' score_display.short_description = 'Балл' @admin.register(HomeworkFile) class HomeworkFileAdmin(admin.ModelAdmin): """Админ интерфейс для файлов ДЗ.""" list_display = [ 'filename', 'file_type_badge', 'homework_link', 'submission_link', 'file_size_display', 'uploaded_by_link', 'created_at' ] list_filter = [ 'file_type', 'created_at' ] search_fields = [ 'filename', 'homework__title', 'uploaded_by__email' ] readonly_fields = [ 'filename', 'file_size', 'uploaded_by', 'created_at' ] def homework_link(self, obj): """Ссылка на ДЗ.""" if obj.homework: url = reverse('admin:homework_homework_change', args=[obj.homework.id]) return format_html('{}', url, obj.homework.title) return '-' homework_link.short_description = 'Задание' def submission_link(self, obj): """Ссылка на решение.""" if obj.submission: url = reverse('admin:homework_homeworksubmission_change', args=[obj.submission.id]) return format_html('Решение #{}', url, obj.submission.id) return '-' submission_link.short_description = 'Решение' def file_type_badge(self, obj): """Бейдж типа файла.""" colors = { 'assignment': '#007bff', 'submission': '#28a745', 'feedback': '#ffc107' } return format_html( '{}', colors.get(obj.file_type, '#000'), obj.get_file_type_display() ) file_type_badge.short_description = 'Тип' def file_size_display(self, obj): """Отображение размера файла.""" size_mb = obj.file_size / (1024 * 1024) if size_mb > 1: return f"{size_mb:.2f} MB" size_kb = obj.file_size / 1024 return f"{size_kb:.2f} KB" file_size_display.short_description = 'Размер' def uploaded_by_link(self, obj): """Ссылка на загрузившего.""" if obj.uploaded_by: url = reverse('admin:users_user_change', args=[obj.uploaded_by.id]) return format_html('{}', url, obj.uploaded_by.get_full_name()) return '-' uploaded_by_link.short_description = 'Загрузил' @admin.register(HomeworkAIAgent) class HomeworkAIAgentAdmin(admin.ModelAdmin): """Админ интерфейс для ИИ-агентов проверки ДЗ.""" list_display = ['name', 'model_name', 'openai_url_short', 'usage_count', 'tokens_short', 'is_default', 'is_active', 'dev_mode', 'order'] list_filter = ['is_default', 'is_active', 'dev_mode'] search_fields = ['name', 'model_name'] list_editable = ['is_default', 'is_active', 'dev_mode', 'order'] ordering = ['order', 'name'] readonly_fields = ['usage_count', 'total_prompt_tokens', 'total_completion_tokens'] fieldsets = ( (None, { 'fields': ('name', 'model_name', 'is_default', 'order', 'is_active') }), ('Системный промпт', { 'fields': ('system_prompt',), 'description': 'Роль и инструкции для модели. Пусто — используется встроенный промпт проверки ДЗ. Перед вашим текстом автоматически добавляется строка «Имя ученика: …» (из отправки ДЗ).' }), ('Параметры генерации (влияют на ответ модели)', { 'fields': ('temperature', 'top_p', 'max_tokens'), 'description': ( 'temperature — случайность ответа (0–2): 0 = стабильный, 2 = разнообразный; для ДЗ обычно 0.3–0.7. ' 'top_p — nucleus sampling (0–1): доля вероятных токенов. ' 'max_tokens — макс. длина ответа в токенах (для развёрнутого комментария 2000–4000). ' 'Пустые поля — используются значения по умолчанию провайдера.' ), }), ('Статистика', { 'fields': ('usage_count', 'total_prompt_tokens', 'total_completion_tokens'), 'description': 'Использований и накопленные токены. Баланс и лимиты — в личном кабинете RouterAI.' }), ('Debug / Режим разработки (AI)', { 'fields': ('dev_mode',), 'description': 'Включить отладочный промпт: ИИ описывает содержимое изображений в комментарии. По умолчанию выключено.' }), ('API (OpenAI-совместимый)', { 'fields': ('openai_url', 'api_key', 'auth_header'), 'description': 'RouterAI: https://routerai.ru/docs/reference — базовый URL https://routerai.ru/api/v1, модели на https://routerai.ru/models, ключ в https://routerai.ru/settings/keys.' }), ) def openai_url_short(self, obj): if obj.openai_url and len(obj.openai_url) > 50: return obj.openai_url[:47] + '...' return obj.openai_url or '—' openai_url_short.short_description = 'OpenAI URL' def tokens_short(self, obj): pt = getattr(obj, 'total_prompt_tokens', 0) or 0 pc = getattr(obj, 'total_completion_tokens', 0) or 0 if pt or pc: return f'{pt:,} / {pc:,}' return '—' tokens_short.short_description = 'Токены (вход/выход)'