191 lines
9.4 KiB
Python
191 lines
9.4 KiB
Python
from django.contrib import admin
|
||
from unfold.admin import ModelAdmin, StackedInline
|
||
from django.db import models
|
||
from django.forms import widgets
|
||
from .models import Subject, Assignment, Submission, Question, Answer
|
||
|
||
@admin.register(Subject)
|
||
class SubjectAdmin(ModelAdmin):
|
||
list_display = ('name',)
|
||
list_display_links = ('name',)
|
||
|
||
class QuestionInline(StackedInline):
|
||
model = Question
|
||
extra = 0
|
||
|
||
class Media:
|
||
js = ('js/admin_assignment.js',)
|
||
|
||
@admin.register(Assignment)
|
||
class AssignmentAdmin(ModelAdmin):
|
||
list_display = ('title', 'subject', 'is_published', 'time_limit', 'deadline', 'allow_late_submissions', 'created_at')
|
||
list_display_links = ('title',)
|
||
list_filter = ('subject', 'is_published', 'allow_late_submissions')
|
||
search_fields = ('title', 'description')
|
||
exclude = ('created_by',)
|
||
filter_horizontal = ('assigned_students',)
|
||
inlines = [QuestionInline]
|
||
actions = ['make_published', 'make_unpublished', 'delete_selected_assignments']
|
||
|
||
@admin.action(description="Опубликовать выбранные задания")
|
||
def make_published(self, request, queryset):
|
||
updated = queryset.update(is_published=True)
|
||
self.message_user(request, f"Опубликовано {updated} заданий.")
|
||
|
||
@admin.action(description="Скрыть выбранные задания (в черновик)")
|
||
def make_unpublished(self, request, queryset):
|
||
updated = queryset.update(is_published=False)
|
||
self.message_user(request, f"Скрыто {updated} заданий.")
|
||
|
||
@admin.action(description="Удалить выбранные задания")
|
||
def delete_selected_assignments(self, request, queryset):
|
||
deleted_count = 0
|
||
for obj in queryset:
|
||
if self.has_delete_permission(request, obj):
|
||
obj.delete()
|
||
deleted_count += 1
|
||
self.message_user(request, f"Успешно удалено {deleted_count} заданий.")
|
||
|
||
def get_queryset(self, request):
|
||
qs = super().get_queryset(request)
|
||
if request.user.is_superuser or request.user.role == 'ADMIN':
|
||
return qs
|
||
return qs.filter(created_by=request.user)
|
||
|
||
def save_model(self, request, obj, form, change):
|
||
if getattr(obj, 'created_by_id', None) is None:
|
||
obj.created_by = request.user
|
||
super().save_model(request, obj, form, change)
|
||
|
||
def has_delete_permission(self, request, obj=None):
|
||
if request.user.is_superuser or request.user.role == 'ADMIN':
|
||
return True
|
||
if request.user.role == 'TEACHER':
|
||
if obj is not None and obj.created_by != request.user:
|
||
return False
|
||
return True
|
||
return False
|
||
|
||
from django.utils.html import format_html
|
||
|
||
class AnswerInline(StackedInline):
|
||
model = Answer
|
||
extra = 0
|
||
readonly_fields = ('question', 'get_student_answer')
|
||
fields = ('question', 'get_student_answer', 'is_correct', 'points', 'comment')
|
||
|
||
def get_student_answer(self, obj):
|
||
if not obj or not obj.pk:
|
||
return ""
|
||
if obj.question.question_type == 'TEXT_AUTO':
|
||
return obj.text_answer or "Нет ответа"
|
||
elif obj.question.question_type == 'TABLE_AUTO':
|
||
import json
|
||
try:
|
||
data = json.loads(obj.text_answer)
|
||
html = '<table style="border-collapse: collapse; margin-top: 4px;">'
|
||
for row in data:
|
||
html += f'<tr><td style="border: 1px solid #e5e7eb; padding: 4px 8px;">{row[0]}</td><td style="border: 1px solid #e5e7eb; padding: 4px 8px;">{row[1]}</td></tr>'
|
||
html += '</table>'
|
||
return format_html(html)
|
||
except Exception:
|
||
return obj.text_answer or "Нет ответа"
|
||
else:
|
||
if obj.file_answer:
|
||
return format_html('<a href="{}" target="_blank">Скачать файл</a>', obj.file_answer.url)
|
||
return "Файл не прикреплен"
|
||
get_student_answer.short_description = 'Ответ ученика'
|
||
|
||
def has_add_permission(self, request, obj=None):
|
||
return False
|
||
|
||
from django.utils.html import format_html
|
||
|
||
@admin.register(Submission)
|
||
class SubmissionAdmin(ModelAdmin):
|
||
change_list_template = "admin/lms/submission/change_list.html"
|
||
list_display = ('assignment', 'student', 'status', 'is_time_expired', 'is_late', 'grade', 'submitted_at')
|
||
list_filter = ('status', 'assignment__subject', 'student', 'is_time_expired', 'is_late')
|
||
search_fields = ('student__username', 'assignment__title')
|
||
inlines = [AnswerInline]
|
||
|
||
fieldsets = (
|
||
('Параметры проверки и оценка', {
|
||
'fields': ('statistics', ('assignment', 'student'), ('grade', 'status'), ('is_time_expired', 'is_late'), 'teacher_feedback')
|
||
}),
|
||
('Временные метки', {
|
||
'fields': (('started_at', 'submitted_at'),)
|
||
}),
|
||
)
|
||
|
||
def get_readonly_fields(self, request, obj=None):
|
||
if obj:
|
||
return ('statistics', 'assignment', 'student', 'is_time_expired', 'is_late', 'started_at', 'submitted_at')
|
||
return ('statistics', 'is_time_expired', 'is_late', 'started_at', 'submitted_at')
|
||
|
||
@admin.display(description='Статистика (сохраните оценку в формате X/Y, чтобы обновить)')
|
||
def statistics(self, obj):
|
||
if not obj or not obj.grade:
|
||
return format_html('<div style="color: #8b949e;">Оценка не выставлена или имеет неверный формат. Укажите оценку в формате "X/Y" (например, "3/27").</div>')
|
||
|
||
try:
|
||
parts = obj.grade.split('/')
|
||
earned = float(parts[0].strip())
|
||
total = float(parts[1].strip())
|
||
percentage = (earned / total) * 100 if total > 0 else 0
|
||
|
||
is_passed = percentage >= 40
|
||
threshold_text = "Выше порога" if is_passed else "Ниже порога (Мин: 40%)"
|
||
color = "#2ea043" if is_passed else "#f85149"
|
||
|
||
total_int = int(total) if total.is_integer() else total
|
||
threshold_points = int(total * 0.4)
|
||
|
||
return format_html(
|
||
'''
|
||
<div style="background-color: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; color: #c9d1d9; font-family: ui-sans-serif, system-ui, sans-serif;">
|
||
<div style="display: flex; justify-content: space-between; align-items: flex-end;">
|
||
<div>
|
||
<div style="font-size: 11px; text-transform: uppercase; color: #8b949e; letter-spacing: 0.05em; margin-bottom: 8px;">Итоговая оценка</div>
|
||
<div style="display: flex; align-items: baseline; gap: 12px;">
|
||
<div style="background-color: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 6px 16px; font-size: 24px; font-weight: bold; color: #fff;">
|
||
{grade}
|
||
</div>
|
||
<span style="font-size: 13px; color: #8b949e;">первичных баллов</span>
|
||
</div>
|
||
</div>
|
||
<div style="flex-grow: 1; max-width: 50%; display: flex; flex-direction: column; justify-content: flex-end;">
|
||
<div style="display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 8px;">
|
||
<span style="color: #c9d1d9;">Выполнение: <b>{percentage:.1f}%</b></span>
|
||
<span style="color: {color}; font-weight: 500;">{threshold_text}</span>
|
||
</div>
|
||
<div style="width: 100%; background-color: #30363d; border-radius: 9999px; height: 6px; overflow: hidden; margin-bottom: 8px;">
|
||
<div style="background-color: {color}; width: {percentage}%; height: 100%; border-radius: 9999px;"></div>
|
||
</div>
|
||
<div style="display: flex; justify-content: space-between; font-size: 11px; color: #8b949e; font-family: ui-monospace, SFMono-Regular, monospace;">
|
||
<span>0 б</span>
|
||
<span>Порог: {threshold_points} б</span>
|
||
<span>Максимум: {total_int} б</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
''',
|
||
grade=obj.grade,
|
||
percentage=percentage,
|
||
threshold_text=threshold_text,
|
||
color=color,
|
||
total_int=total_int,
|
||
threshold_points=threshold_points
|
||
)
|
||
except Exception:
|
||
return format_html('<div style="color: #f85149;">Оценка имеет неверный формат. Используйте формат "X/Y" (например, "3/27").</div>')
|
||
|
||
def get_queryset(self, request):
|
||
qs = super().get_queryset(request)
|
||
if request.user.is_superuser or request.user.role == 'ADMIN':
|
||
return qs
|
||
if request.user.role == 'TEACHER':
|
||
return qs.filter(assignment__created_by=request.user)
|
||
return qs.filter(student=request.user)
|