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