Phase 3: individual defense assignments with invite codes

- progress V5 defense_sessions: per student+lab individual task (md), status
  machine waiting -> started -> ready, one-time 6-digit codes
- 30-min timer with server-side lazy expiry (syncTimeouts: started->ready by
  timeout), countdown JS on student page
- admin: assign task dialog (+prefill existing via data-md), CSV import
  (studentNo;task, \n for newlines) resolved via identity students,
  issue/reset code buttons, defense status column
- student: code entry form (code hidden until start), assignment md,
  'Готов к сдаче', waiting-for-teacher note
- gateway: /api/v1/defenses,/api/v1/defenses/** routes
- web api client + admin/student controllers wired
This commit is contained in:
2026-09-12 00:54:10 +03:00
parent e1da254599
commit d6fc47794e
14 changed files with 634 additions and 6 deletions

View File

@@ -98,7 +98,7 @@
<h3 class="group-title" th:text="${g.groupName}">Группа</h3>
<div class="card card-flush mb-6">
<table>
<thead><tr><th>Студент</th><th>Логин</th><th>Уровень</th><th>Вариант</th><th>Источник</th><th>Сдача</th></tr></thead>
<thead><tr><th>Студент</th><th>Логин</th><th>Уровень</th><th>Вариант</th><th>Источник</th><th>Сдача</th><th>Защита</th></tr></thead>
<tbody>
<tr th:each="s : ${g.students}">
<td th:text="${s.fullName}">—</td>
@@ -134,14 +134,63 @@
th:href="'/admin/submissions/' + ${s.submissionId} + '/download'"
th:text="|попытка ${s.submissionSeq}|">↓</a>
<span th:unless="${s.submissionId}" class="text-muted">не сдано</span></td>
<td>
<span th:if="${s.defenseStatus == null}" class="text-muted">не назначено</span>
<span th:if="${s.defenseStatus == 'waiting' and s.defenseCode != null}" class="badge badge-warn">код <span class="font-mono" th:text="${s.defenseCode}"></span></span>
<span th:if="${s.defenseStatus == 'waiting' and s.defenseCode == null}" class="badge badge-warn">ждёт задания</span>
<span th:if="${s.defenseStatus == 'started'}" class="badge badge-warn">на защите (<span th:text="${s.defenseSecondsLeft}"></span>с)</span>
<span th:if="${s.defenseStatus == 'ready'}" class="badge badge-ok">готов к защите</span>
<form class="inline-form" th:if="${s.defenseSessionId != null}" th:action="'/admin/defenses/' + ${s.defenseSessionId} + '/issue-code'" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" th:value="${lab.id}">
<button class="btn btn-secondary btn-sm" type="submit" th:if="${s.defenseStatus != 'ready'}">выдать код</button>
<button class="btn btn-secondary btn-sm" type="submit" th:if="${s.defenseStatus == 'ready'}">заново</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(g.students)}"><td colspan="7" class="empty">В группе нет студентов.</td></tr>
<tr th:if="${#lists.isEmpty(g.students)}"><td colspan="8" class="empty">В группе нет студентов.</td></tr>
</tbody>
</table>
</div>
</div>
<h2 class="section-title mt-8">Индивидуальные задания для защиты</h2>
<div class="flex flex-wrap gap-2 mb-4">
<button class="btn btn-primary" type="button" onclick="openModal('modal-defense-assign')">Выдать / изменить задание</button>
<form class="inline-form" th:action="'/admin/labs/' + ${lab.id} + '/defense/import'" method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input class="input input-sm" type="file" name="file" accept=".csv" required>
<button class="btn btn-secondary btn-sm" type="submit">Импорт CSV</button>
</form>
</div>
<p class="text-sm text-muted mb-4">CSV: строки «студенческий номер;текст задания»; переносы внутри задания — как \n. Первая строка с заголовком игнорируется.</p>
<dialog id="modal-defense-assign" class="modal">
<div class="modal-header">
<h2 class="modal-title">Индивидуальное задание</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-defense-assign')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="'/admin/labs/' + ${lab.id} + '/defense/assign'" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<div><label class="label">Студент</label>
<select class="input" id="defense-student" name="studentId" required onchange="fillAssignment(this)">
<option value="">— выберите студента —</option>
<optgroup th:each="g : ${groups}" th:label="${g.groupName}">
<option th:each="s : ${g.students}" th:value="${s.studentId}"
th:attr="data-md=${s.adminAssignmentMd}"
th:text="${s.fullName}">—</option>
</optgroup>
</select></div>
<div><label class="label">Задание (markdown)</label>
<textarea class="input" id="defense-md" name="assignmentMd" rows="8" required></textarea></div>
<button class="btn btn-primary" type="submit">Сохранить задание</button>
</form>
</div>
</dialog>
<dialog id="modal-meta" class="modal">
<div class="modal-header">
<h2 class="modal-title">Правка лабораторной</h2>
@@ -255,6 +304,7 @@
</th:block>
<script>
function fillAssignment(sel) { document.getElementById("defense-md").value = sel.options[sel.selectedIndex].getAttribute("data-md") || ""; }
function filterGroups(name) {
document.querySelectorAll('.group-section').forEach(function (el) {
el.classList.toggle('hidden', !!name && el.getAttribute('data-group') !== name);