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)
|
||||
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]
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user