fix: закрыть P0/P1 расхождения с целевой моделью

- академический отпуск: readOnly, запрет submit/start/finish защиты
- черновики лабораторных скрыты от студентов в списке/деталях/по UUID
- удалено действие «Убрать из группы» из UI и API
- импорт: restoreInactiveStatuses, skipped-отчёт, разблокировка архивного аккаунта
- глобальные материалы STUDENTS/TEACHERS с JWT-фильтрацией и сменой аудитории
- LDD-AUTH-005 переведён на unit-evidence, clean check проходит по P0
- backup: безопасный парсинг .env, verify-latest, кавычки cron
- robots/noindex, комментарии без автора, профиль без черновиков
- docs/18-goal-audit.md; baseline 57 claims / 366 тестов / release PASS
This commit is contained in:
2026-10-29 18:58:21 +03:00
parent 9cb4d0d263
commit 9412d87fed
63 changed files with 1281 additions and 162 deletions

View File

@@ -135,12 +135,19 @@ class AdminGroupController(
@PostMapping("/groups/import")
fun importGroup(
@RequestParam("file") file: MultipartFile,
@RequestParam(defaultValue = "false") restoreInactiveStatuses: Boolean,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
if (file.isEmpty) throw ApiException(400, "Выберите файл со списком группы")
val result = api.importGroup(token(session), file.originalFilename ?: "group.xlsx", file.bytes)
val result =
api.importGroup(
token(session),
file.originalFilename ?: "group.xlsx",
file.bytes,
restoreInactiveStatuses,
)
redirect.addFlashAttribute("importResult", result)
if (!result.applied) {
redirect.addFlashAttribute("error", "Импорт не применён: исправьте ошибки в файле")

View File

@@ -21,7 +21,6 @@ import ru.ysu.lms.web.api.credentials
import ru.ysu.lms.web.api.enrollStudent
import ru.ysu.lms.web.api.groupDetail
import ru.ysu.lms.web.api.resetPassword
import ru.ysu.lms.web.api.unenrollStudent
import tools.jackson.databind.ObjectMapper
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
@@ -119,20 +118,4 @@ class AdminStudentController(
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups/$groupId"
}
@PostMapping("/groups/{groupId}/students/{studentId}/unenroll")
fun unenrollStudent(
@PathVariable groupId: String,
@PathVariable studentId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.unenrollStudent(token(session), groupId, studentId)
redirect.addFlashAttribute("notice", "Студент отчислен: аккаунт и данные сохранены, вход закрыт")
"redirect:/admin/groups/$groupId"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups/$groupId"
}
}

View File

@@ -33,6 +33,7 @@ import ru.ysu.lms.web.api.reorderArticles
import ru.ysu.lms.web.api.reorderContent
import ru.ysu.lms.web.api.reorderSections
import ru.ysu.lms.web.api.setContentPublished
import ru.ysu.lms.web.api.updateChapterAudience
import ru.ysu.lms.web.api.updateContentArticle
import ru.ysu.lms.web.session.SessionKeys
@@ -153,16 +154,18 @@ class ContentAdminController(
redirectBack(back, "redirect:/learn")
}
@Suppress("LongParameterList")
@PostMapping("/chapters/create")
fun createChapter(
@RequestParam title: String,
@RequestParam(required = false) slug: String?,
@RequestParam(required = false) audience: String?,
@RequestParam(required = false) back: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.createChapter(token(session), title.trim(), slug)
api.createChapter(token(session), title.trim(), slug, audience)
redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана")
redirectBack(back, "redirect:/learn")
} catch (ex: ApiException) {
@@ -170,6 +173,23 @@ class ContentAdminController(
redirectBack(back, "redirect:/learn")
}
@PostMapping("/chapters/{id}/audience")
fun updateChapterAudience(
@PathVariable id: String,
@RequestParam audience: String,
@RequestParam(required = false) back: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.updateChapterAudience(token(session), id, audience)
redirect.addFlashAttribute("notice", "Аудитория главы изменена")
redirectBack(back, "redirect:/learn")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectBack(back, "redirect:/learn")
}
@PostMapping("/chapters/{id}/rename")
fun renameChapter(
@PathVariable id: String,

View File

@@ -1,3 +1,5 @@
@file:Suppress("TooManyFunctions")
package ru.ysu.lms.web.api
import org.springframework.core.ParameterizedTypeReference
@@ -165,6 +167,7 @@ fun ApiClient.createChapter(
token: String,
title: String,
slug: String?,
audience: String? = null,
): Map<String, Any> {
val type = object : ParameterizedTypeReference<Map<String, Any>>() {}
return call {
@@ -173,12 +176,29 @@ fun ApiClient.createChapter(
.uri("/api/v1/content/chapters")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(mapOf("title" to title, "slug" to slug))
.body(mapOf("title" to title, "slug" to slug, "audience" to audience))
.retrieve()
.body(type)
}
}
fun ApiClient.updateChapterAudience(
token: String,
chapterId: String,
audience: String,
) {
call {
client
.put()
.uri("/api/v1/content/chapters/{id}/audience", chapterId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(mapOf("audience" to audience))
.retrieve()
.toBodilessEntity()
}
}
fun ApiClient.renameChapter(
token: String,
chapterId: String,

View File

@@ -39,6 +39,7 @@ fun ApiClient.importGroup(
token: String,
filename: String,
bytes: ByteArray,
restoreInactiveStatuses: Boolean = false,
): ImportResultDto {
val parts = LinkedMultiValueMap<String, Any>()
parts.add(
@@ -50,8 +51,12 @@ fun ApiClient.importGroup(
return call {
client
.post()
.uri("/api/v1/groups/import")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.uri { builder ->
builder
.path("/api/v1/groups/import")
.queryParam("restoreInactiveStatuses", restoreInactiveStatuses)
.build()
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(parts)
.retrieve()
@@ -102,21 +107,6 @@ fun ApiClient.enrollStudent(
}
}
fun ApiClient.unenrollStudent(
token: String,
groupId: String,
studentId: String,
) {
call {
client
.delete()
.uri("/api/v1/groups/{id}/students/{sid}", groupId, studentId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.toBodilessEntity()
}
}
fun ApiClient.resetPassword(
token: String,
studentId: String,

View File

@@ -19,6 +19,7 @@ data class AuthResponseDto(
val role: String = "",
val name: String = "",
val mustChangePassword: Boolean = false,
val readOnly: Boolean = false,
)
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -29,6 +30,7 @@ data class MeDto(
val name: String = "",
val mustChangePassword: Boolean = false,
val active: Boolean = true,
val readOnly: Boolean = false,
)
data class LoginPayload(
@@ -94,6 +96,7 @@ data class ImportResultDto(
val groupName: String? = null,
val added: List<CredentialDto> = emptyList(),
val updated: List<String> = emptyList(),
val skipped: List<ImportErrorDto> = emptyList(),
val errors: List<ImportErrorDto> = emptyList(),
)
@@ -597,6 +600,7 @@ data class ContentChapterNodeDto(
val slug: String = "",
val position: Int? = 0,
val published: Boolean = true,
val audience: String? = null,
val sections: List<ContentSectionNodeDto> = emptyList(),
)

View File

@@ -150,6 +150,7 @@ class AuthPageController(
role = auth.role,
name = auth.name,
mustChangePassword = auth.mustChangePassword,
readOnly = auth.readOnly,
),
)
}

View File

@@ -23,8 +23,18 @@ class NoCachePageInterceptor : HandlerInterceptor {
handler: Any,
): Boolean {
response.setHeader("Cache-Control", "no-store")
if (isPrivatePath(request.requestURI)) {
response.setHeader("X-Robots-Tag", "noindex, nofollow")
}
return true
}
private fun isPrivatePath(uri: String): Boolean = uri in PRIVATE_EXACT || PRIVATE_PREFIXES.any(uri::startsWith)
private companion object {
val PRIVATE_EXACT = setOf("/login", "/setup", "/admin", "/teacher", "/student", "/change-password")
val PRIVATE_PREFIXES = listOf("/admin/", "/teacher/", "/student/", "/change-password", "/api/", "/learn/course/")
}
}
@Configuration

View File

@@ -121,10 +121,10 @@ class AuthInterceptor(
user: SessionUser,
me: MeDto,
) {
if (me.role == user.role && me.mustChangePassword == user.mustChangePassword) return
if (me.role == user.role && me.mustChangePassword == user.mustChangePassword && me.readOnly == user.readOnly) return
session.setAttribute(
SessionKeys.USER,
user.copy(role = me.role, mustChangePassword = me.mustChangePassword),
user.copy(role = me.role, mustChangePassword = me.mustChangePassword, readOnly = me.readOnly),
)
}

View File

@@ -51,7 +51,11 @@ class SessionTokenService(
)
session.setAttribute(
SessionKeys.USER,
user.copy(role = auth.role, mustChangePassword = auth.mustChangePassword),
user.copy(
role = auth.role,
mustChangePassword = auth.mustChangePassword,
readOnly = auth.readOnly,
),
)
RefreshOutcome.OK
} catch (ex: ApiException) {

View File

@@ -6,6 +6,7 @@ data class SessionUser(
val role: String,
val name: String,
val mustChangePassword: Boolean,
val readOnly: Boolean = false,
) {
val isAdmin: Boolean get() = role == "ADMIN"
val isTeacher: Boolean get() = role == "TEACHER"

View File

@@ -155,7 +155,9 @@ class StudentController(
defense?.let { it.assignmentHtml = if (it.assignmentMd.isNotBlank()) markdown.render(it.assignmentMd) else "" }
model.addAttribute("defense", defense)
val submittedOrDefending = defense != null && (defense.status == "waiting" || defense.assignmentMd.isNotBlank())
model.addAttribute("canSubmit", canSubmit(lab.deadlineAt) && !submittedOrDefending)
val readOnly = (session.getAttribute(SessionKeys.USER) as? ru.ysu.lms.web.session.SessionUser)?.readOnly == true
model.addAttribute("readOnly", readOnly)
model.addAttribute("canSubmit", !readOnly && canSubmit(lab.deadlineAt) && !submittedOrDefending)
"student/lab"
} catch (ignore: ApiException) {
"redirect:/student/labs"

View File

@@ -0,0 +1,9 @@
User-agent: *
Allow: /learn/
Disallow: /learn/course/
Disallow: /admin/
Disallow: /teacher/
Disallow: /student/
Disallow: /change-password
Disallow: /api/
Disallow: /login

View File

@@ -240,15 +240,6 @@
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary btn-sm" type="submit">Восстановить в группу</button>
</form>
<form th:if="${row.status == 'ACTIVE' and !teacherMode}"
th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}"
method="post"
data-confirm="Убрать студента из этой группы? Зачисление будет удалено. Для обычного отчисления с сохранением истории используйте «Отчислить».">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary btn-sm" type="submit"
title="Удалить зачисление в группу; для отчисления с сохранением истории используйте «Отчислить»">Убрать из группы</button>
</form>
</div>
</td>
</tr>
@@ -487,6 +478,23 @@
<p class="text-sm" th:if="${!#lists.isEmpty(importResult.updated)}">
Обновлено существующих студентов: <b th:text="${#lists.size(importResult.updated)}">0</b>
</p>
<div class="text-sm" th:if="${!#lists.isEmpty(importResult.skipped)}">
<p>
Пропущено строк: <b th:text="${#lists.size(importResult.skipped)}">0</b> —
статус и группа не изменены, требуется явное восстановление:
</p>
<div class="scroll-x">
<table>
<thead><tr><th>Строка</th><th>Причина</th></tr></thead>
<tbody>
<tr th:each="s : ${importResult.skipped}">
<td th:text="${s.row}">—</td>
<td th:text="${s.message}">—</td>
</tr>
</tbody>
</table>
</div>
</div>
<p class="hint">Пароли показываются один раз — сохраните их или воспользуйтесь «Скачать пароли».</p>
</div>
</div>

View File

@@ -101,6 +101,16 @@
</p>
<div><label class="label" for="file">Файл со списком</label>
<input class="input" id="file" name="file" type="file" accept=".xls,.xlsx,.csv" required autofocus></div>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" id="restoreInactiveStatuses" name="restoreInactiveStatuses" value="true">
<span>Восстанавливать отчисленных/выпущенных/архивных/академических</span>
</label>
<p class="hint">
Без флага студенты в статусах «отчислен», «выпущен», «архив» или «академический отпуск»
обновляются только по ФИО и базе: их статус и группа не меняются, а строки попадут
в отчёт как пропущенные. С флагом такие студенты снова станут активными и будут
зачислены в импортируемую группу.
</p>
<button class="btn btn-primary" type="submit">Импортировать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>

View File

@@ -32,6 +32,17 @@
<h2 class="section-title sort-title m-0" th:text="${chapter.title}">Глава</h2>
<span th:if="${!chapter.published}" class="badge badge-warn">скрыто</span>
<div th:if="${currentUser != null and currentUser.admin}" class="sort-actions">
<form th:if="${chapter.audience != null}" class="inline-form"
th:action="@{/admin/content/chapters/{id}/audience(id=${chapter.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" value="/learn">
<select class="input filter-select" name="audience" onchange="this.form.submit()"
title="Аудитория главы — изменится сразу">
<option value="ALL" th:selected="${chapter.audience == 'ALL'}">Все</option>
<option value="STUDENTS" th:selected="${chapter.audience == 'STUDENTS'}">Студенты</option>
<option value="TEACHERS" th:selected="${chapter.audience == 'TEACHERS'}">Преподаватели</option>
</select>
</form>
<form class="inline-form" th:action="@{/admin/content/chapters/toggle-published}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${chapter.id}">
@@ -206,6 +217,17 @@
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<div><label class="label" for="chTitle">Название</label>
<input class="input" id="chTitle" name="title" required></div>
<div><label class="label" for="chAudience">Аудитория</label>
<select class="input" id="chAudience" name="audience">
<option value="ALL" selected>Все — доступно без входа</option>
<option value="STUDENTS">Студенты — после входа с ролью студента</option>
<option value="TEACHERS">Преподаватели — после входа с ролью преподавателя</option>
</select>
<p class="hint">
Главы на этой странице глобальные. <b>Все</b> видит любой посетитель,
<b>Студенты</b> и <b>Преподаватели</b> — только авторизованные пользователи
с соответствующей ролью. Изменить аудиторию можно потом в карточке главы.
</p></div>
<button class="btn btn-primary" type="submit">Создать</button>
</form>
</div>

View File

@@ -170,6 +170,7 @@
<span th:text="${comment.text}">—</span>
<span class="text-sm text-muted" th:if="${comment.author != null}"
th:text="|— ${comment.author.login}|"></span>
<span class="text-sm text-muted" th:if="${comment.author == null}">— автор не указан</span>
<span class="text-sm text-muted" th:if="${comment.createdAtMsk != null or comment.createdAt != null}"
th:text="|(${comment.createdAtMsk ?: comment.createdAt})|"></span>
</li>

View File

@@ -27,7 +27,8 @@
<h2 class="section-title">Сдача работы</h2>
<div class="card form-card form-stack mb-4">
<p class="text-sm text-muted" th:if="${!canSubmit}">Приём решений закрыт. Вы по-прежнему можете скачать отправленные работы ниже.</p>
<p class="text-sm text-muted" th:if="${readOnly}">Академический отпуск: доступ только для чтения. Сдавать решения и проходить защиты нельзя.</p>
<p class="text-sm text-muted" th:if="${!readOnly and !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>
@@ -87,7 +88,7 @@
<div th:if="${defense == null}" class="text-muted">
Индивидуальное задание для защиты ещё не назначено. Преподаватель сообщит, когда можно приходить.
</div>
<th:block th:if="${defense != null and defense.status == 'waiting'}">
<th:block th:if="${defense != null and defense.status == 'waiting' and !readOnly}">
<p class="text-sm">Преподаватель уже выдал вам одноразовый код. Подойдите на защиту и введите его:</p>
<form class="inline-form" th:action="@{/student/defenses/start}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
@@ -102,7 +103,7 @@
<p class="text-sm defense-timer" th:attr="data-seconds=${defense.secondsLeft},data-lab=${lab.id}"></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" data-confirm="Завершить защиту? Продолжить её будет нельзя — дождитесь преподавателя.">
<form class="mt-4" th:if="${!readOnly}" th:action="'/student/defenses/' + ${defense.id} + '/finish?labId=' + ${lab.id}" method="post" data-confirm="Завершить защиту? Продолжить её будет нельзя — дождитесь преподавателя.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-primary" type="submit">Готов к сдаче</button>
</form>

View File

@@ -12,6 +12,8 @@ import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
import org.thymeleaf.web.servlet.JakartaServletWebApplication
import ru.ysu.lms.web.api.AdminUserDto
import ru.ysu.lms.web.api.GroupDetailDto
import ru.ysu.lms.web.api.ImportErrorDto
import ru.ysu.lms.web.api.ImportResultDto
import ru.ysu.lms.web.api.LabSummaryDto
import ru.ysu.lms.web.api.PromotionItemDto
import ru.ysu.lms.web.api.PromotionRunDetailDto
@@ -204,13 +206,65 @@ class AdminStage4TemplateTest {
assertThat(html).contains("/admin/groups/g1/archive")
assertThat(html).contains("/admin/groups/g1/students/s1/academic-leave")
assertThat(html).contains("/admin/groups/g1/students/s1/expel")
assertThat(html).contains("/admin/groups/g1/students/s1/unenroll")
assertThat(html).doesNotContain("/admin/groups/g1/students/s1/unenroll")
assertThat(html).contains("/admin/groups/g1/students/s2/return")
assertThat(html).contains("/admin/groups/g1/students/s3/restore")
assertThat(html).contains("/admin/groups/g1/students/s4/restore")
assertThat(html).contains("Вернуть в группу")
assertThat(html).contains("Восстановить в группу")
assertThat(html).contains("Убрать из группы")
assertThat(html).doesNotContain("Убрать из группы")
}
@Test
fun `group import form requires explicit restoration flag`() {
val html = render("admin/groups", mapOf("groups" to emptyList<GroupDetailDto>()))
assertThat(html).contains("name=\"restoreInactiveStatuses\"")
assertThat(html).contains("value=\"true\"")
assertThat(html).contains("Восстанавливать отчисленных/выпущенных/архивных/академических")
}
@Test
fun `group import result shows skipped inactive rows`() {
val group =
GroupDetailDto(
id = "g1",
name = "ПИ-21БО",
status = "ACTIVE",
students = emptyList(),
)
val model =
linkedMapOf<String, Any?>(
"group" to group,
"labs" to emptyList<LabSummaryDto>(),
"candidates" to emptyList<Any>(),
"studentGrades" to emptyList<GroupStudentRow>(),
"labPublication" to emptyMap<String, GroupLabPublication>(),
"labSubmissionCounts" to emptyMap<String, Int>(),
"attestations" to emptyList<GroupAttestation>(),
"publishedLabsCount" to 0,
"profileCourseId" to null,
"teacherView" to false,
"importResult" to
ImportResultDto(
applied = true,
groupId = "g1",
groupName = "ПИ-21БО",
skipped =
listOf(
ImportErrorDto(
row = 7,
message = "Студент 2001 в статусе EXPELLED: статус и группа не изменены",
),
),
),
)
val html = render("admin/group", model)
assertThat(html).contains("Пропущено строк")
assertThat(html).contains("Студент 2001 в статусе EXPELLED")
assertThat(html).contains("7")
}
@Test

View File

@@ -51,6 +51,7 @@ class TeacherGradebookTemplateTest {
assertThat(html).doesNotContain("/students/s1/archive")
assertThat(html).doesNotContain("/students/s1/expel")
assertThat(html).doesNotContain("/students/s1/unenroll")
assertThat(html).doesNotContain("Убрать из группы")
assertThat(html).doesNotContain("/attestations")
assertThat(html).doesNotContain("modal-add-attestation")
assertThat(html).doesNotContain("modal-add-student")

View File

@@ -0,0 +1,27 @@
package ru.ysu.lms.web.config
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.mock.web.MockHttpServletResponse
class NoCachePageInterceptorTest {
private val interceptor = NoCachePageInterceptor()
@Test
fun `private pages are not indexable`() {
listOf("/admin/groups", "/teacher", "/student/labs", "/login", "/api/v1/courses", "/learn/course/1").forEach { uri ->
val response = MockHttpServletResponse()
interceptor.preHandle(MockHttpServletRequest("GET", uri), response, Any())
assertThat(response.getHeader("X-Robots-Tag")).contains("noindex")
}
}
@Test
fun `public materials remain indexable`() {
val response = MockHttpServletResponse()
interceptor.preHandle(MockHttpServletRequest("GET", "/learn"), response, Any())
assertThat(response.getHeader("X-Robots-Tag")).isNull()
assertThat(response.getHeader("Cache-Control")).isEqualTo("no-store")
}
}