uchill/backend/apps/homework/admin.py

464 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Административная панель для домашних заданий.
"""
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('<a href="{}">{}</a>', 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('<a href="{}">{}</a>', url, obj.lesson.title)
return '-'
lesson_link.short_description = 'Занятие'
def status_badge(self, obj):
"""Бейдж статуса."""
colors = {
'draft': '#6c757d',
'published': '#28a745',
'archived': '#ffc107'
}
return format_html(
'<span style="background-color: {}; color: white; padding: 3px 10px; border-radius: 3px;">{}</span>',
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('<a href="{}">{}</a>', 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('<a href="{}">{}</a>', 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(
'<span style="background-color: {}; color: white; padding: 3px 10px; border-radius: 3px;">{}</span>',
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('<a href="{}">{}</a>', 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('<a href="{}">Решение #{}</a>', 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(
'<span style="background-color: {}; color: white; padding: 3px 10px; border-radius: 3px;">{}</span>',
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('<a href="{}">{}</a>', 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 — случайность ответа (02): 0 = стабильный, 2 = разнообразный; для ДЗ обычно 0.30.7. '
'top_p — nucleus sampling (01): доля вероятных токенов. '
'max_tokens — макс. длина ответа в токенах (для развёрнутого комментария 20004000). '
'Пустые поля — используются значения по умолчанию провайдера.'
),
}),
('Статистика', {
'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 = 'Токены (вход/выход)'