feat(lms): replace is_closed with timer limit and auto-submit logic
This commit is contained in:
+4
-4
@@ -18,9 +18,9 @@ class QuestionInline(StackedInline):
|
|||||||
|
|
||||||
@admin.register(Assignment)
|
@admin.register(Assignment)
|
||||||
class AssignmentAdmin(ModelAdmin):
|
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_display_links = ('title',)
|
||||||
list_filter = ('subject', 'is_published', 'is_closed')
|
list_filter = ('subject', 'is_published')
|
||||||
search_fields = ('title', 'description')
|
search_fields = ('title', 'description')
|
||||||
exclude = ('created_by',)
|
exclude = ('created_by',)
|
||||||
filter_horizontal = ('assigned_students',)
|
filter_horizontal = ('assigned_students',)
|
||||||
@@ -102,8 +102,8 @@ class AnswerInline(StackedInline):
|
|||||||
@admin.register(Submission)
|
@admin.register(Submission)
|
||||||
class SubmissionAdmin(ModelAdmin):
|
class SubmissionAdmin(ModelAdmin):
|
||||||
change_list_template = "admin/lms/submission/change_list.html"
|
change_list_template = "admin/lms/submission/change_list.html"
|
||||||
list_display = ('assignment', 'student', 'status', 'grade', 'submitted_at')
|
list_display = ('assignment', 'student', 'status', 'is_time_expired', 'grade', 'submitted_at')
|
||||||
list_filter = ('status', 'assignment__subject', 'student')
|
list_filter = ('status', 'assignment__subject', 'student', 'is_time_expired')
|
||||||
search_fields = ('student__username', 'assignment__title')
|
search_fields = ('student__username', 'assignment__title')
|
||||||
inlines = [AnswerInline]
|
inlines = [AnswerInline]
|
||||||
|
|
||||||
|
|||||||
@@ -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_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)
|
created_at = models.DateTimeField('Дата создания', auto_now_add=True)
|
||||||
deadline = models.DateTimeField('Дедлайн', blank=True, null=True, help_text='После этого времени ответы не принимаются.')
|
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:
|
class Meta:
|
||||||
verbose_name = 'Задание'
|
verbose_name = 'Задание'
|
||||||
@@ -56,6 +56,7 @@ class Question(models.Model):
|
|||||||
|
|
||||||
class Submission(models.Model):
|
class Submission(models.Model):
|
||||||
STATUS_CHOICES = (
|
STATUS_CHOICES = (
|
||||||
|
('IN_PROGRESS', 'В процессе выполнения'),
|
||||||
('PENDING', 'Ожидает проверки'),
|
('PENDING', 'Ожидает проверки'),
|
||||||
('GRADED', 'Проверено'),
|
('GRADED', 'Проверено'),
|
||||||
)
|
)
|
||||||
@@ -64,7 +65,9 @@ class Submission(models.Model):
|
|||||||
grade = models.CharField('Оценка', max_length=10, blank=True, null=True)
|
grade = models.CharField('Оценка', max_length=10, blank=True, null=True)
|
||||||
teacher_feedback = models.TextField('Комментарий учителя', blank=True, null=True)
|
teacher_feedback = models.TextField('Комментарий учителя', blank=True, null=True)
|
||||||
status = models.CharField('Статус', max_length=20, choices=STATUS_CHOICES, default='PENDING')
|
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)
|
submitted_at = models.DateTimeField('Дата отправки', auto_now_add=True)
|
||||||
|
is_time_expired = models.BooleanField('Время вышло', default=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = 'Ответ ученика'
|
verbose_name = 'Ответ ученика'
|
||||||
|
|||||||
@@ -94,13 +94,87 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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 %}
|
{% elif not can_submit %}
|
||||||
<div class="mb-6 p-4 rounded-lg border bg-red-50 text-red-800 border-red-200">
|
<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>
|
<h4 class="font-bold mb-2">Прием ответов завершен</h4>
|
||||||
<p>Вы больше не можете отправлять ответы, так как дедлайн истек или преподаватель закрыл задание.</p>
|
<p>Вы больше не можете отправлять ответы, так как дедлайн истек или время на выполнение закончилось.</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% 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 %}
|
{% csrf_token %}
|
||||||
|
|
||||||
{% for q, field_type, field in question_fields %}
|
{% for q, field_type, field in question_fields %}
|
||||||
|
|||||||
@@ -52,15 +52,15 @@
|
|||||||
Добавлено: {{ assignment.created_at|date:"d M Y" }}
|
Добавлено: {{ assignment.created_at|date:"d M Y" }}
|
||||||
</span>
|
</span>
|
||||||
{% if assignment.deadline %}
|
{% 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>
|
<span class="material-icons text-[14px]">timer</span>
|
||||||
Дедлайн: {{ assignment.deadline|date:"d M Y, H:i" }}
|
Дедлайн: {{ assignment.deadline|date:"d M Y, H:i" }}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if assignment.is_closed %}
|
{% if assignment.time_limit %}
|
||||||
<span class="flex items-center gap-1 text-red-500 font-medium mt-1">
|
<span class="flex items-center gap-1 text-indigo-600 font-medium mt-1">
|
||||||
<span class="material-icons text-[14px]">lock</span>
|
<span class="material-icons text-[14px]">hourglass_empty</span>
|
||||||
Прием ответов закрыт
|
Время на решение: {{ assignment.time_limit }} мин.
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+51
-11
@@ -62,23 +62,53 @@ def assignment_detail(request, pk):
|
|||||||
submission = Submission.objects.filter(assignment=assignment, student=request.user).first()
|
submission = Submission.objects.filter(assignment=assignment, student=request.user).first()
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
can_submit = True
|
can_submit = True
|
||||||
if assignment.is_closed:
|
time_left_seconds = None
|
||||||
can_submit = False
|
needs_start = False
|
||||||
if assignment.deadline and timezone.now() > assignment.deadline:
|
|
||||||
|
if assignment.deadline and now > assignment.deadline:
|
||||||
can_submit = False
|
can_submit = False
|
||||||
|
|
||||||
if submission:
|
if assignment.time_limit:
|
||||||
if request.method == 'POST':
|
if not submission:
|
||||||
# Do not allow updating
|
needs_start = True
|
||||||
return redirect('lms:dashboard')
|
can_submit = False # Can't submit until started
|
||||||
form = None
|
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:
|
else:
|
||||||
if not can_submit:
|
time_left_seconds = int(allowed - elapsed)
|
||||||
form = None
|
if time_left_seconds < 0:
|
||||||
elif request.method == 'POST':
|
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)
|
form = AnswersForm(request.POST, request.FILES, questions=questions)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
|
if not submission:
|
||||||
submission = Submission.objects.create(assignment=assignment, student=request.user)
|
submission = Submission.objects.create(assignment=assignment, student=request.user)
|
||||||
|
|
||||||
all_auto = True
|
all_auto = True
|
||||||
@@ -86,7 +116,10 @@ def assignment_detail(request, pk):
|
|||||||
max_points = 0
|
max_points = 0
|
||||||
|
|
||||||
for q in questions:
|
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)
|
answer_obj = Answer(submission=submission, question=q)
|
||||||
|
|
||||||
if q.question_type == 'TEXT_AUTO':
|
if q.question_type == 'TEXT_AUTO':
|
||||||
val = form.cleaned_data.get(f'answer_{q.id}', '')
|
val = form.cleaned_data.get(f'answer_{q.id}', '')
|
||||||
answer_obj.text_answer = val
|
answer_obj.text_answer = val
|
||||||
@@ -142,7 +175,12 @@ def assignment_detail(request, pk):
|
|||||||
submission.save()
|
submission.save()
|
||||||
return redirect('lms:dashboard')
|
return redirect('lms:dashboard')
|
||||||
else:
|
else:
|
||||||
|
form = None
|
||||||
|
else:
|
||||||
|
if can_submit and not needs_start:
|
||||||
form = AnswersForm(questions=questions)
|
form = AnswersForm(questions=questions)
|
||||||
|
else:
|
||||||
|
form = None
|
||||||
|
|
||||||
question_fields = []
|
question_fields = []
|
||||||
if form:
|
if form:
|
||||||
@@ -165,4 +203,6 @@ def assignment_detail(request, pk):
|
|||||||
'questions': questions,
|
'questions': questions,
|
||||||
'question_fields': question_fields,
|
'question_fields': question_fields,
|
||||||
'can_submit': can_submit,
|
'can_submit': can_submit,
|
||||||
|
'needs_start': needs_start,
|
||||||
|
'time_left_seconds': time_left_seconds,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user