Phase 7: deployment, data import, UI hardening and security fixes

Docker/prod deployment:
- multi-stage Dockerfile (JDK21 build, JRE21 run), docker-compose.prod.yml,
  nginx config for lms.our-place.ru, deploy.sh; DEPLOY/IMPORT guides (UI flow)
- Fix Docker 401: LMS_JWT_JWK_SET_URI for academic/progress; forward-headers

Variant distribution (lab page only):
- round-robin by student level, shared random queue across selected groups,
  least-used variants, manual pins always preserved; occupied/dup labels
- setup page: single form + one «Сохранить всё», batch level/variant for
  selected students (checkboxes)

Grades, defense, materials:
- publication gate + comment lists; defense CSV import (2/3 cols, quotes)
- additional task after defense ("начать заново"); enroll/unenroll students
  from group page; temporary-password lock (no navigation until changed) and
  persistent "Смена пароля"; forced change once
- chapter page /learn/{c}; header: LMS home, Войти, user -> dashboard,
  compact theme toggle; responsive mobile header
- markdown: single newlines are soft breaks; highlight.js with light/dark themes
- lab/content imports ZIP-only (lab.yaml+README+variants/assets,
  chapters tree+assets); duplicate lab numbers rejected; variant ordering
  and date defaults honored

Fixes:
- 500 on student create (@MapsId set before persist); broken dialogs/JS refs in group page
This commit is contained in:
2026-09-14 22:01:46 +03:00
parent 5e60cc3819
commit cdf4fb0ee0
58 changed files with 3967 additions and 884 deletions

View File

@@ -6,13 +6,7 @@
<a th:href="@{/student/labs}" class="nav-link">← Лабораторные</a>
<div class="flex flex-wrap items-center justify-between gap-4 mt-4 mb-2">
<h1 class="page-title" th:text="|Лабораторная работа ${lab.number}. ${lab.title}|">ЛР</h1>
<div class="flex gap-2 text-sm">
<span th:if="${grade != null}" class="badge badge-ok">Оценка: <span th:text="${grade}"></span></span>
<span th:each="c : ${gradeComments}" class="chip"
th:classappend="${c.tone == 'positive'} ? chip-pos : (${c.tone == 'negative'} ? chip-neg : chip-neu)"
th:text="${c.text}">коммент</span>
<span th:if="${assignment != null}" class="badge badge-warn">Вариант <span class="font-mono" th:text="${assignment.variantNumber}"></span></span>
</div>
<span th:if="${assignment != null}" class="badge badge-warn">Вариант <span class="font-mono" th:text="${assignment.variantNumber}"></span></span>
</div>
<p class="text-sm text-muted mb-6" th:if="${deadlineMsk}">Срок сдачи: <span th:text="${deadlineMsk}"></span> МСК</p>
@@ -31,14 +25,66 @@
<p class="text-muted">Ваш вариант ещё не распределён. Дождитесь, пока преподаватель распределит варианты.</p>
</div>
<h2 class="section-title">Сдача работы</h2>
<div class="card form-card form-stack mb-4">
<p class="text-sm text-muted" th:if="${!canSubmit}">Приём решений закрыт (истёк срок сдачи или уже назначено индивидуальное задание на защиту). Вы по-прежнему можете скачать отправленные работы ниже.</p>
<form th:if="${canSubmit}" th:action="@{/student/labs/{id}/submit(id=${lab.id})}" method="post" enctype="multipart/form-data" class="form-stack">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<p class="text-sm text-muted">zip‑архив или исходники (.c/.h). Можно пересдавать сколько угодно раз до истечения срока — засчитывается последняя попытка.</p>
<div><input class="input" name="files" type="file" multiple required></div>
<button class="btn btn-primary" type="submit">Отправить решение</button>
</form>
<div th:if="${canSubmit and #lists.isEmpty(submissions)}" class="text-muted text-sm">Вы ещё ничего не сдавали.</div>
</div>
<h3 class="group-title">История сдач</h3>
<div class="card card-flush mb-6">
<table>
<thead><tr><th>Попытка</th><th>Когда (МСК)</th><th>Файлы</th><th></th></tr></thead>
<tbody>
<tr th:each="s : ${submissions}">
<td th:text="${s.seq}">1</td>
<td class="text-muted" th:text="${s.createdAt}"></td>
<td class="font-mono text-sm"><span th:each="f : ${s.files}" th:text="${f}"></span></td>
<td><a class="btn btn-secondary btn-sm" th:href="'/student/submissions/' + ${s.id} + '/download'">Скачать</a></td>
</tr>
<tr th:if="${#lists.isEmpty(submissions)}"><td colspan="4" class="empty">Нет сдач.</td></tr>
</tbody>
</table>
</div>
<th:block th:if="${canSeeGrade}">
<h2 class="section-title">Оценка и комментарии</h2>
<div class="card card-body mb-6">
<p class="mb-4">Ваша оценка за лабораторную: <span class="badge badge-ok font-mono" th:text="${grade}"></span></p>
<div class="review-cols">
<div class="review-col" th:if="${!#lists.isEmpty(commentsPositive)}">
<h3 class="group-title tone-positive-text">Позитивный</h3>
<ul class="review-list"><li th:each="c : ${commentsPositive}" th:text="${c.text}">—</li></ul>
</div>
<div class="review-col" th:if="${!#lists.isEmpty(commentsNegative)}">
<h3 class="group-title tone-negative-text">Негативный</h3>
<ul class="review-list"><li th:each="c : ${commentsNegative}" th:text="${c.text}">—</li></ul>
</div>
<div class="review-col" th:if="${!#lists.isEmpty(commentsNeutral)}">
<h3 class="group-title">Нейтральный</h3>
<ul class="review-list"><li th:each="c : ${commentsNeutral}" th:text="${c.text}">—</li></ul>
</div>
</div>
<p class="text-sm text-muted"
th:if="${#lists.isEmpty(commentsPositive) and #lists.isEmpty(commentsNegative) and #lists.isEmpty(commentsNeutral)}">
Комментариев к оценке нет.
</p>
</div>
</th:block>
<h2 class="section-title">Защита лабораторной</h2>
<div class="card card-body form-stack">
<div th:if="${defense == null}" class="text-muted">
Индивидуальное задание для защиты ещё не назначено. Преподаватель сообщит, когда можно приходить.
</div>
<th:block th:if="${defense != null and defense.status == 'waiting'}">
<p class="text-sm">Подойдите к преподавателю: он выдаст вам одноразовый код для начала защиты.</p>
<p class="text-sm">Преподаватель уже выдал вам одноразовый код. Подойдите на защиту и введите его:</p>
<form class="inline-form" th:action="@{/student/defenses/start}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" th:value="${lab.id}">
@@ -47,7 +93,7 @@
</form>
</th:block>
<div th:if="${defense != null and defense.status == 'started'}">
<p class="text-sm defense-timer" th:data-seconds="${defense.secondsLeft}" data-labid="${lab.id}"></p>
<p class="text-sm defense-timer" th:data-seconds="${defense.secondsLeft}"></p>
<h3 class="group-title">Ваше индивидуальное задание</h3>
<div class="md-body" th:utext="${defense.assignmentHtml}"></div>
<form class="mt-4" th:action="'/student/defenses/' + ${defense.id} + '/finish?labId=' + ${lab.id}" method="post">
@@ -55,52 +101,12 @@
<button class="btn btn-primary" type="submit">Готов к сдаче</button>
</form>
</div>
<script>
document.querySelectorAll('.defense-timer').forEach(function (el) {
var left = parseInt(el.getAttribute('data-seconds') || '0', 10);
var tick = setInterval(function () {
left -= 1;
if (left <= 0) { clearInterval(tick); window.location.reload(); return; }
var m = Math.floor(left / 60), s = left % 60;
el.textContent = 'Осталось времени: ' + m + ':' + (s < 10 ? '0' : '') + s;
}, 1000);
var m = Math.floor(left / 60), s = left % 60;
el.textContent = 'Осталось времени: ' + m + ':' + (s < 10 ? '0' : '') + s;
});
</script>
<div th:if="${defense != null and defense.status == 'ready'}">
<p class="badge badge-ok defense-note">Завершено (<span th:text="${defense.readyReason == 'timeout' ? 'истекло время' : 'по кнопке'}"></span>).</p>
<p class="badge badge-ok">Завершено (<span th:text="${defense.readyReason == 'timeout' ? 'истекло время' : 'по кнопке'}"></span>).</p>
<p class="text-sm text-muted">Ожидайте преподавателя с проверкой.</p>
</div>
</div>
<h2 class="section-title">Сдача работы</h2>
<div class="card form-card form-stack mb-4">
<form th:action="@{/student/labs/{id}/submit(id=${lab.id})}" method="post" enctype="multipart/form-data" class="form-stack">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<p class="text-sm text-muted">zip‑архив или исходники (.c/.h). Пока не истёк срок, можно пересывать столько раз, сколько нужно — засчитывается последняя попытка.</p>
<div><input class="input" name="files" type="file" multiple required></div>
<div th:if="${canSubmit}">
<button class="btn btn-primary" type="submit">Отправить решение</button>
</div>
<div th:unless="${canSubmit}" class="alert alert-warning">Срок сдачи истёк — отправка закрыта.</div>
</form>
</div>
<h3 class="group-title">История сдач</h3>
<div class="card card-flush">
<table>
<thead><tr><th>Попытка</th><th>Когда (МСК)</th><th>Файлы</th><th></th></tr></thead>
<tbody>
<tr th:each="s : ${submissions}">
<td th:text="${s.seq}">1</td>
<td class="text-muted" th:text="${#strings.replace(s.createdAt, 'T', ' ')}"></td>
<td class="font-mono text-sm"><span th:each="f : ${s.files}" th:text="${f}"></span></td>
<td><a class="btn btn-secondary btn-sm" th:href="'/student/submissions/' + ${s.id} + '/download'">Скачать</a></td>
</tr>
<tr th:if="${#lists.isEmpty(submissions)}"><td colspan="4" class="empty">Вы ещё ничего не сдавали.</td></tr>
</tbody>
</table>
</div>
</div></body>
<script src="/js/defense-timer.js"></script>
</div>
</body>
</html>