feat(lms): replace is_closed with timer limit and auto-submit logic

This commit is contained in:
Dmitry Belan
2026-09-20 17:58:23 +03:00
parent 4b1ea99a4d
commit b3e9d9ae18
5 changed files with 142 additions and 25 deletions
+4 -4
View File
@@ -18,9 +18,9 @@ class QuestionInline(StackedInline):
@admin.register(Assignment)
class AssignmentAdmin(ModelAdmin):
list_display = ('title', 'subject', 'is_published', 'is_closed', 'deadline', 'created_at')
list_display = ('title', 'subject', 'is_published', 'time_limit', 'deadline', 'created_at')
list_display_links = ('title',)
list_filter = ('subject', 'is_published', 'is_closed')
list_filter = ('subject', 'is_published')
search_fields = ('title', 'description')
exclude = ('created_by',)
filter_horizontal = ('assigned_students',)
@@ -102,8 +102,8 @@ class AnswerInline(StackedInline):
@admin.register(Submission)
class SubmissionAdmin(ModelAdmin):
change_list_template = "admin/lms/submission/change_list.html"
list_display = ('assignment', 'student', 'status', 'grade', 'submitted_at')
list_filter = ('status', 'assignment__subject', 'student')
list_display = ('assignment', 'student', 'status', 'is_time_expired', 'grade', 'submitted_at')
list_filter = ('status', 'assignment__subject', 'student', 'is_time_expired')
search_fields = ('student__username', 'assignment__title')
inlines = [AnswerInline]
+4 -1
View File
@@ -23,7 +23,7 @@ class Assignment(models.Model):
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, limit_choices_to={'role__in': ['TEACHER', 'ADMIN']}, verbose_name='Кем создано')
created_at = models.DateTimeField('Дата создания', auto_now_add=True)
deadline = models.DateTimeField('Дедлайн', blank=True, null=True, help_text='После этого времени ответы не принимаются.')
is_closed = models.BooleanField('Прием ответов закрыт', default=False, help_text='Включите, чтобы вручную закрыть прием ответов на задание.')
time_limit = models.PositiveIntegerField('Лимит времени (минуты)', blank=True, null=True, help_text='Например, 235 для 3ч 55м. Оставьте пустым, если без таймера.')
class Meta:
verbose_name = 'Задание'
@@ -56,6 +56,7 @@ class Question(models.Model):
class Submission(models.Model):
STATUS_CHOICES = (
('IN_PROGRESS', 'В процессе выполнения'),
('PENDING', 'Ожидает проверки'),
('GRADED', 'Проверено'),
)
@@ -64,7 +65,9 @@ class Submission(models.Model):
grade = models.CharField('Оценка', max_length=10, blank=True, null=True)
teacher_feedback = models.TextField('Комментарий учителя', blank=True, null=True)
status = models.CharField('Статус', max_length=20, choices=STATUS_CHOICES, default='PENDING')
started_at = models.DateTimeField('Время начала', blank=True, null=True)
submitted_at = models.DateTimeField('Дата отправки', auto_now_add=True)
is_time_expired = models.BooleanField('Время вышло', default=False)
class Meta:
verbose_name = 'Ответ ученика'
@@ -94,13 +94,87 @@
</div>
{% endfor %}
</div>
{% elif needs_start %}
<div class="mb-6 p-8 text-center rounded-2xl border bg-indigo-50 border-indigo-200">
<span class="material-icons text-indigo-500 text-6xl mb-4">hourglass_top</span>
<h4 class="text-2xl font-bold mb-2 text-indigo-900">Задание на время</h4>
<p class="text-indigo-800 text-lg mb-6">На выполнение этого задания отводится <strong>{{ assignment.time_limit }} минут(ы)</strong>. Как только вы нажмете кнопку ниже, запустится обратный отсчет. По истечении времени ваши ответы будут отправлены автоматически.</p>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="start">
<button type="submit" class="px-8 py-4 bg-indigo-600 text-white text-lg font-bold rounded-xl shadow hover:bg-indigo-700 transition">
Начать выполнение
</button>
</form>
</div>
{% elif not can_submit %}
<div class="mb-6 p-4 rounded-lg border bg-red-50 text-red-800 border-red-200">
<h4 class="font-bold mb-2">Прием ответов завершен</h4>
<p>Вы больше не можете отправлять ответы, так как дедлайн истек или преподаватель закрыл задание.</p>
<p>Вы больше не можете отправлять ответы, так как дедлайн истек или время на выполнение закончилось.</p>
</div>
{% else %}
<form method="post" enctype="multipart/form-data" class="space-y-6">
{% if time_left_seconds is not None %}
<div id="timer-banner" class="sticky top-4 z-50 mb-6 p-4 rounded-xl border bg-indigo-600 text-white shadow-lg flex justify-between items-center">
<div class="flex items-center gap-2">
<span class="material-icons animate-pulse">timer</span>
<span class="font-medium text-lg">Осталось времени:</span>
</div>
<div class="text-2xl font-mono font-bold tracking-wider" id="countdown-display">
--:--:--
</div>
</div>
<script>
let timeLeft = {{ time_left_seconds }};
const display = document.getElementById('countdown-display');
const form = document.getElementById('assignment-form');
function updateTimer() {
if (timeLeft <= 0) {
display.textContent = '00:00:00';
display.classList.add('text-red-300');
// Auto submit
if (form) {
// Prevent multiple submissions
form.onsubmit = null;
const btn = form.querySelector('button[type="submit"]');
if (btn) btn.disabled = true;
// Append a hidden flag
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'auto_submit';
input.value = '1';
form.appendChild(input);
form.submit();
}
return;
}
const h = Math.floor(timeLeft / 3600);
const m = Math.floor((timeLeft % 3600) / 60);
const s = timeLeft % 60;
display.textContent =
(h > 0 ? (h < 10 ? '0' + h : h) + ':' : '') +
(m < 10 ? '0' + m : m) + ':' +
(s < 10 ? '0' + s : s);
if (timeLeft < 60) {
display.classList.add('text-red-300', 'animate-pulse');
}
timeLeft--;
setTimeout(updateTimer, 1000);
}
if (timeLeft > 0) {
updateTimer();
}
</script>
{% endif %}
<form id="assignment-form" method="post" enctype="multipart/form-data" class="space-y-6">
{% csrf_token %}
{% for q, field_type, field in question_fields %}
+5 -5
View File
@@ -52,15 +52,15 @@
Добавлено: {{ assignment.created_at|date:"d M Y" }}
</span>
{% if assignment.deadline %}
<span class="flex items-center gap-1 {% if assignment.is_closed or assignment.deadline < now %}text-red-500{% else %}text-gray-500{% endif %}">
<span class="flex items-center gap-1 {% if assignment.deadline < now %}text-red-500{% else %}text-gray-500{% endif %}">
<span class="material-icons text-[14px]">timer</span>
Дедлайн: {{ assignment.deadline|date:"d M Y, H:i" }}
</span>
{% endif %}
{% if assignment.is_closed %}
<span class="flex items-center gap-1 text-red-500 font-medium mt-1">
<span class="material-icons text-[14px]">lock</span>
Прием ответов закрыт
{% if assignment.time_limit %}
<span class="flex items-center gap-1 text-indigo-600 font-medium mt-1">
<span class="material-icons text-[14px]">hourglass_empty</span>
Время на решение: {{ assignment.time_limit }} мин.
</span>
{% endif %}
</div>
+51 -11
View File
@@ -62,23 +62,53 @@ def assignment_detail(request, pk):
submission = Submission.objects.filter(assignment=assignment, student=request.user).first()
from django.utils import timezone
import datetime
now = timezone.now()
can_submit = True
if assignment.is_closed:
can_submit = False
if assignment.deadline and timezone.now() > assignment.deadline:
time_left_seconds = None
needs_start = False
if assignment.deadline and now > assignment.deadline:
can_submit = False
if submission:
if request.method == 'POST':
# Do not allow updating
return redirect('lms:dashboard')
form = None
if assignment.time_limit:
if not submission:
needs_start = True
can_submit = False # Can't submit until started
elif submission.status == 'IN_PROGRESS':
if submission.started_at:
elapsed = (now - submission.started_at).total_seconds()
allowed = assignment.time_limit * 60
if elapsed > allowed + 60: # 1 minute grace period for network latency
can_submit = False
submission.is_time_expired = True
submission.status = 'PENDING' # Or graded with 0
submission.save()
else:
if not can_submit:
form = None
elif request.method == 'POST':
time_left_seconds = int(allowed - elapsed)
if time_left_seconds < 0:
time_left_seconds = 0
else:
can_submit = False # Already submitted
if request.method == 'POST':
if needs_start and 'action' in request.POST and request.POST['action'] == 'start':
Submission.objects.create(
assignment=assignment,
student=request.user,
status='IN_PROGRESS',
started_at=now
)
return redirect('lms:assignment_detail', pk=pk)
if submission and submission.status != 'IN_PROGRESS':
return redirect('lms:dashboard')
if can_submit and not needs_start:
form = AnswersForm(request.POST, request.FILES, questions=questions)
if form.is_valid():
if not submission:
submission = Submission.objects.create(assignment=assignment, student=request.user)
all_auto = True
@@ -86,7 +116,10 @@ def assignment_detail(request, pk):
max_points = 0
for q in questions:
answer_obj = Answer.objects.filter(submission=submission, question=q).first()
if not answer_obj:
answer_obj = Answer(submission=submission, question=q)
if q.question_type == 'TEXT_AUTO':
val = form.cleaned_data.get(f'answer_{q.id}', '')
answer_obj.text_answer = val
@@ -142,7 +175,12 @@ def assignment_detail(request, pk):
submission.save()
return redirect('lms:dashboard')
else:
form = None
else:
if can_submit and not needs_start:
form = AnswersForm(questions=questions)
else:
form = None
question_fields = []
if form:
@@ -165,4 +203,6 @@ def assignment_detail(request, pk):
'questions': questions,
'question_fields': question_fields,
'can_submit': can_submit,
'needs_start': needs_start,
'time_left_seconds': time_left_seconds,
})