Compare commits

...

11 Commits

Author SHA1 Message Date
Dmitry Belan 53b9a76848 Auto-calculate points and final grade when saving submission 2026-09-28 23:47:49 +03:00
Dmitry Belan d7e1364426 Change threshold to raw points and add custom points per question 2026-09-28 23:41:58 +03:00
Dmitry Belan 85d138280d Organize Assignment form in admin to make passing_threshold visible 2026-09-28 23:39:10 +03:00
Dmitry Belan 646bc81724 Change default passing threshold to 1 2026-09-28 23:36:39 +03:00
Dmitry Belan 7009f90a6c Add customizable passing threshold and display statistics to students 2026-09-28 23:35:35 +03:00
Dmitry Belan 1a8da1351f Remove conflicting 0011 migration 2026-09-28 23:28:40 +03:00
Dmitry Belan 93f5709683 Fix tuple index out of range in format_html for statistics 2026-09-28 22:59:43 +03:00
Dmitry Belan 08eba4f9d7 Add statistics block to SubmissionAdmin 2026-09-28 22:46:32 +03:00
Dmitry Belan c7d443e2ee Add dashboard tabs for sorting tasks by status 2026-09-28 22:40:18 +03:00
Dmitry Belan 96d4ed2425 Add short_description to Assignment for dashboard card preview 2026-09-28 22:27:58 +03:00
Dmitry Belan 431056e69e Add late and expired time badges to custom admin submission list 2026-09-28 22:25:50 +03:00
7 changed files with 274 additions and 35 deletions
+102 -3
View File
@@ -22,7 +22,19 @@ class AssignmentAdmin(ModelAdmin):
list_display_links = ('title',)
list_filter = ('subject', 'is_published', 'allow_late_submissions')
search_fields = ('title', 'description')
exclude = ('created_by',)
fieldsets = (
('Основная информация', {
'fields': ('title', 'subject', 'short_description', 'description')
}),
('Настройки доступа и времени', {
'fields': ('is_published', 'assign_to_all', 'assigned_students', 'deadline', 'time_limit')
}),
('Настройки проверки', {
'fields': ('allow_late_submissions', 'passing_threshold')
}),
)
filter_horizontal = ('assigned_students',)
inlines = [QuestionInline]
actions = ['make_published', 'make_unpublished', 'delete_selected_assignments']
@@ -99,6 +111,8 @@ class AnswerInline(StackedInline):
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"
@@ -107,10 +121,95 @@ class SubmissionAdmin(ModelAdmin):
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 ('assignment', 'student', 'is_time_expired', 'is_late', 'started_at', 'submitted_at')
return ('is_time_expired', 'is_late', 'started_at', 'submitted_at')
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):
stats = obj.get_statistics() if obj else None
if not stats:
return format_html('<div style="color: #8b949e;">Оценка не выставлена или имеет неверный формат. Укажите оценку в формате "X/Y" (например, "3/27").</div>')
try:
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_str}%</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_str}%; 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_str=stats['percentage_str'],
threshold_text=stats['threshold_text'],
color=stats['color'],
total_int=stats['total'],
threshold_points=stats['threshold_points']
)
except Exception as e:
return format_html(
'<div style="color: #f85149;">Ошибка при отрисовке статистики: {error}</div>',
error=str(e)
)
def save_formset(self, request, form, formset, change):
instances = formset.save(commit=False)
for instance in instances:
if isinstance(instance, Answer):
if instance.is_correct and instance.points is None:
instance.points = instance.question.points
elif instance.is_correct is False and instance.points is None:
instance.points = 0
instance.save()
for obj in formset.deleted_objects:
obj.delete()
formset.save_m2m()
if formset.model == Answer:
submission = formset.instance
total_earned = sum((ans.points or 0) for ans in submission.answers.all())
total_max = sum((q.points or 1) for q in submission.assignment.questions.all())
new_grade = f"{total_earned}/{total_max}"
if submission.grade != new_grade:
submission.grade = new_grade
submission.save(update_fields=['grade'])
def get_queryset(self, request):
qs = super().get_queryset(request)
+31
View File
@@ -15,6 +15,7 @@ class Subject(models.Model):
class Assignment(models.Model):
title = models.CharField('Название задания', max_length=255)
short_description = models.TextField('Краткое описание (для карточки)', blank=True, null=True, help_text='Отображается на главной странице в карточке задания. Если пусто, используется начало полного описания.')
description = RichTextUploadingField('Описание задания', blank=True, null=True)
subject = models.ForeignKey(Subject, on_delete=models.CASCADE, verbose_name='Предмет')
is_published = models.BooleanField('Опубликовано', default=True, help_text='Снимите галочку, чтобы скрыть задание (как черновик).')
@@ -25,6 +26,7 @@ class Assignment(models.Model):
deadline = models.DateTimeField('Дедлайн', blank=True, null=True, help_text='После этого времени ответы не принимаются.')
time_limit = models.PositiveIntegerField('Лимит времени (минуты)', blank=True, null=True, help_text='Например, 235 для 3ч 55м. Оставьте пустым, если без таймера.')
allow_late_submissions = models.BooleanField('Разрешить дорешивание', default=True, help_text='Если включено, ученики смогут сдавать задание после дедлайна с пометкой "После дедлайна".')
passing_threshold = models.PositiveIntegerField('Порог прохождения (в баллах)', default=10, help_text='Количество баллов, необходимое для прохождения. Например, 10.')
class Meta:
verbose_name = 'Задание'
@@ -45,6 +47,7 @@ class Question(models.Model):
question_type = models.CharField('Тип ответа', max_length=20, choices=TYPE_CHOICES, default='TEXT_AUTO')
table_rows = models.PositiveIntegerField('Количество строк в таблице', default=2, blank=True, null=True, help_text='Укажите количество строк. Столбцов всегда 2.')
correct_answer = models.CharField('Правильный ответ (для автопроверки)', max_length=255, blank=True, null=True, help_text='Оставьте пустым для ручной проверки. Для таблицы вводите ответы в формате "Ответ1|Ответ2;Ответ3|Ответ4" (строки через точку с запятой, столбцы через прямую черту).')
points = models.PositiveIntegerField('Баллы за правильный ответ', default=1, help_text='Количество баллов, которое дается за эту задачу (для автопроверки).')
order = models.PositiveIntegerField('Порядок', default=0)
class Meta:
@@ -79,6 +82,34 @@ class Submission(models.Model):
def __str__(self):
return f"Ответ от {self.student.username} на {self.assignment.title}"
def get_statistics(self):
if not self.grade:
return None
try:
parts = self.grade.split('/')
earned = float(parts[0].strip())
total = float(parts[1].strip())
percentage = (earned / total) * 100 if total > 0 else 0
threshold_points = self.assignment.passing_threshold
is_passed = earned >= threshold_points
total_int = int(total) if total.is_integer() else total
earned_int = int(earned) if earned.is_integer() else earned
return {
'earned': earned_int,
'total': total_int,
'percentage': percentage,
'percentage_str': f"{percentage:.1f}",
'is_passed': is_passed,
'threshold_points': threshold_points,
'threshold_text': 'Выше порога' if is_passed else f'Ниже порога (Мин: {threshold_points} б)',
'color': '#2ea043' if is_passed else '#f85149'
}
except Exception:
return None
class Answer(models.Model):
submission = models.ForeignKey(Submission, on_delete=models.CASCADE, related_name='answers', verbose_name='Решение (Submission)')
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='answers', verbose_name='Задача')
@@ -38,6 +38,12 @@
<div class="mt-2 text-sm text-gray-600 dark:text-gray-400 pl-2">
<p class="flex items-center gap-1 mb-1"><span class="material-symbols-outlined text-[16px]">person</span> {{ result.student.first_name|default:result.student.username }}</p>
<p class="flex items-center gap-1"><span class="material-symbols-outlined text-[16px]">schedule</span> {{ result.submitted_at|date:"d M Y, H:i" }}</p>
{% if result.is_late %}
<p class="flex items-center gap-1 mt-1 text-xs font-medium text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-900/20 py-0.5 px-2 rounded-md w-fit border border-red-100 dark:border-red-800/30"><span class="material-symbols-outlined text-[14px]">error</span> Сдано после дедлайна</p>
{% endif %}
{% if result.is_time_expired %}
<p class="flex items-center gap-1 mt-1 text-xs font-medium text-orange-500 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20 py-0.5 px-2 rounded-md w-fit border border-orange-100 dark:border-orange-800/30"><span class="material-symbols-outlined text-[14px]">timer_off</span> Время вышло</p>
{% endif %}
</div>
<div class="mt-4 pl-2">
<a href="{% url 'admin:lms_submission_change' result.pk %}" class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-white bg-primary-600 hover:bg-primary-700 transition">
@@ -66,6 +72,12 @@
<p class="flex items-center gap-1 mb-1"><span class="material-symbols-outlined text-[16px]">person</span> {{ result.student.first_name|default:result.student.username }}</p>
<p class="flex items-center gap-1 font-medium text-green-600 dark:text-green-400"><span class="material-symbols-outlined text-[16px]">done_all</span> Оценка: {{ result.grade|default:"-" }}</p>
<p class="flex items-center gap-1 mt-1 text-xs"><span class="material-symbols-outlined text-[14px]">event</span> {{ result.submitted_at|date:"d M Y, H:i" }}</p>
{% if result.is_late %}
<p class="flex items-center gap-1 mt-1 text-xs font-medium text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-900/20 py-0.5 px-2 rounded-md w-fit border border-red-100 dark:border-red-800/30"><span class="material-symbols-outlined text-[14px]">error</span> Сдано после дедлайна</p>
{% endif %}
{% if result.is_time_expired %}
<p class="flex items-center gap-1 mt-1 text-xs font-medium text-orange-500 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20 py-0.5 px-2 rounded-md w-fit border border-orange-100 dark:border-orange-800/30"><span class="material-symbols-outlined text-[14px]">timer_off</span> Время вышло</p>
{% endif %}
</div>
<div class="mt-4 pl-2">
<a href="{% url 'admin:lms_submission_change' result.pk %}" class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-gray-600 text-xs font-medium rounded text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
@@ -37,6 +37,50 @@
<h3 class="text-lg font-bold text-white mb-6 font-heading border-b border-obsidian-border pb-3">Ваш ответ</h3>
{% if submission and submission.status != 'IN_PROGRESS' %}
{% with stats=submission.get_statistics %}
{% if stats %}
<div class="mb-8 p-6 rounded-xl border bg-obsidian-base border-obsidian-borderLight shadow-sm">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div class="flex items-center gap-4">
<div class="flex-shrink-0">
<div class="font-mono text-[10px] text-txt-faint uppercase tracking-wider mb-1">Итоговая оценка</div>
<div class="flex items-baseline gap-2">
<div class="bg-obsidian-card border border-obsidian-border rounded-lg px-4 py-2 text-2xl font-bold text-white shadow-sm">
{{ submission.grade }}
</div>
<span class="text-xs text-txt-muted">первичных баллов</span>
</div>
</div>
</div>
<div class="flex-grow max-w-md w-full">
<div class="flex justify-between items-end mb-2">
<span class="text-sm text-txt-secondary">Выполнение: <b class="text-white">{{ stats.percentage_str }}%</b></span>
<span class="text-xs font-medium px-2 py-0.5 rounded-full" style="color: {{ stats.color }}; background-color: {{ stats.color }}15; border: 1px solid {{ stats.color }}30;">
{{ stats.threshold_text }}
</span>
</div>
<div class="w-full bg-obsidian-border rounded-full height-1.5 overflow-hidden mb-2" style="height: 6px;">
<div class="h-full rounded-full transition-all duration-1000" style="background-color: {{ stats.color }}; width: {{ stats.percentage_str }}%;"></div>
</div>
<div class="flex justify-between text-[10px] text-txt-muted font-mono uppercase tracking-wide">
<span>0 б</span>
<span>Порог: {{ stats.threshold_points }} б</span>
<span>Максимум: {{ stats.total }} б</span>
</div>
</div>
</div>
{% if submission.teacher_feedback %}
<div class="mt-6 pt-5 border-t border-obsidian-borderLight">
<p class="font-mono text-[10px] uppercase text-accent-blue mb-2 tracking-wider">Комментарий преподавателя</p>
<p class="text-sm text-txt-secondary leading-relaxed bg-obsidian-card p-4 rounded-lg border border-obsidian-border shadow-inner">
{{ submission.teacher_feedback }}
</p>
</div>
{% endif %}
</div>
{% else %}
<div class="mb-8 p-5 rounded-lg border {% if submission.status == 'GRADED' %}bg-emerald-950/20 text-emerald-300 border-emerald-800/40{% else %}bg-amber-950/20 text-amber-300 border-amber-800/40{% endif %}">
{% if submission.status == 'GRADED' %}
<h4 class="font-bold mb-2 flex items-center gap-2 font-heading">
@@ -59,6 +103,8 @@
</div>
{% endif %}
</div>
{% endif %}
{% endwith %}
<h4 class="font-bold mb-5 text-white font-heading">Ваши ответы на задачи:</h4>
<div class="space-y-6">
+46 -2
View File
@@ -10,10 +10,26 @@
</div>
{% if assignments %}
<!-- Tabs -->
<div class="flex items-center gap-6 border-b border-obsidian-borderLight mb-6 overflow-x-auto no-scrollbar">
<button onclick="filterTasks('all')" id="tab-all" class="text-sm font-medium pb-3 border-b-2 transition-colors border-accent-blue text-white whitespace-nowrap">
Все задания <span class="ml-1 px-1.5 py-0.5 rounded bg-obsidian-base border border-obsidian-border text-xs text-txt-secondary">{{ total_count }}</span>
</button>
<button onclick="filterTasks('waiting')" id="tab-waiting" class="text-sm font-medium pb-3 border-b-2 border-transparent text-txt-muted hover:text-white transition-colors whitespace-nowrap">
Ожидают решения <span class="ml-1 px-1.5 py-0.5 rounded bg-obsidian-base border border-obsidian-border text-xs text-txt-secondary">{{ waiting_solution_count }}</span>
</button>
<button onclick="filterTasks('pending')" id="tab-pending" class="text-sm font-medium pb-3 border-b-2 border-transparent text-txt-muted hover:text-white transition-colors whitespace-nowrap">
Ожидает проверки <span class="ml-1 px-1.5 py-0.5 rounded bg-obsidian-base border border-amber-900/50 text-xs text-amber-500/80">{{ pending_count }}</span>
</button>
<button onclick="filterTasks('graded')" id="tab-graded" class="text-sm font-medium pb-3 border-b-2 border-transparent text-txt-muted hover:text-white transition-colors whitespace-nowrap">
Проверено <span class="ml-1 px-1.5 py-0.5 rounded bg-obsidian-base border border-emerald-900/50 text-xs text-emerald-500/80">{{ graded_count }}</span>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5" data-purpose="task-cards-grid">
{% for assignment in assignments %}
{% get_submission submissions assignment.id as sub %}
<article class="bg-obsidian-card rounded border border-obsidian-border hover:border-obsidian-borderLight p-6 flex flex-col justify-between transition-colors duration-150 relative" data-purpose="task-card">
<article class="task-card bg-obsidian-card rounded border border-obsidian-border hover:border-obsidian-borderLight p-6 flex flex-col justify-between transition-colors duration-150 relative" data-status="{% if not sub or sub.status == 'IN_PROGRESS' %}waiting{% elif sub.status == 'PENDING' %}pending{% elif sub.status == 'GRADED' %}graded{% endif %}">
<div>
<!-- Header Badges -->
<div class="flex items-center justify-between gap-2 mb-4">
@@ -71,7 +87,11 @@
{{ assignment.title }}
</h2>
{% if assignment.description %}
{% if assignment.short_description %}
<div class="mb-5 text-sm text-txt-secondary font-body whitespace-pre-line">
{{ assignment.short_description }}
</div>
{% elif assignment.description %}
<div class="mb-5 line-clamp-3 text-sm text-txt-secondary font-body">
{{ assignment.description|striptags }}
</div>
@@ -142,4 +162,28 @@
</div>
{% endif %}
<script>
function filterTasks(status) {
const cards = document.querySelectorAll('.task-card');
cards.forEach(card => {
if (status === 'all' || card.dataset.status === status) {
card.style.display = 'flex';
} else {
card.style.display = 'none';
}
});
const tabs = ['all', 'waiting', 'pending', 'graded'];
tabs.forEach(t => {
const btn = document.getElementById('tab-' + t);
if (t === status) {
btn.classList.add('border-accent-blue', 'text-white');
btn.classList.remove('border-transparent', 'text-txt-muted');
} else {
btn.classList.remove('border-accent-blue', 'text-white');
btn.classList.add('border-transparent', 'text-txt-muted');
}
});
}
</script>
{% endblock %}
+16 -9
View File
@@ -40,16 +40,23 @@ def dashboard(request):
is_published=True
).distinct().order_by('-created_at')
# Check which ones the student has submitted
submissions = Submission.objects.filter(student=request.user)
submitted_assignment_ids = submissions.values_list('assignment_id', flat=True)
submissions_dict = {sub.assignment_id: sub for sub in submissions}
total_count = assignments.count()
waiting_solution_count = sum(1 for a in assignments if a.id not in submissions_dict or submissions_dict[a.id].status == 'IN_PROGRESS')
pending_count = sum(1 for a in assignments if a.id in submissions_dict and submissions_dict[a.id].status == 'PENDING')
graded_count = sum(1 for a in assignments if a.id in submissions_dict and submissions_dict[a.id].status == 'GRADED')
from django.utils import timezone
return render(request, 'lms/dashboard.html', {
'assignments': assignments,
'submitted_assignment_ids': submitted_assignment_ids,
'submissions': {sub.assignment_id: sub for sub in submissions},
'now': timezone.now()
'submissions': submissions_dict,
'now': timezone.now(),
'total_count': total_count,
'waiting_solution_count': waiting_solution_count,
'pending_count': pending_count,
'graded_count': graded_count,
})
@login_required
@@ -135,11 +142,11 @@ def assignment_detail(request, pk):
if q.correct_answer:
correct = strip_tags(q.correct_answer).strip().lower()
answer_obj.is_correct = (val.strip().lower() == correct)
answer_obj.points = 1 if answer_obj.is_correct else 0
answer_obj.points = q.points if answer_obj.is_correct else 0
total_points += answer_obj.points
else:
all_auto = False
max_points += 1
max_points += q.points
elif q.question_type == 'TABLE_AUTO':
rows = getattr(q, 'table_rows', 2) or 2
table_data = []
@@ -164,11 +171,11 @@ def assignment_detail(request, pk):
is_correct = False
break
answer_obj.is_correct = is_correct
answer_obj.points = 1 if is_correct else 0
answer_obj.points = q.points if is_correct else 0
total_points += answer_obj.points
else:
all_auto = False
max_points += 1
max_points += q.points
else:
all_auto = False
val = form.cleaned_data.get(f'file_{q.id}')
Binary file not shown.