"""
Административная панель для домашних заданий.
"""
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 = 'Токены (вход/выход)'