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:
@@ -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", "Импорт не применён: исправьте ошибки в файле")
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
@@ -150,6 +150,7 @@ class AuthPageController(
|
||||
role = auth.role,
|
||||
name = auth.name,
|
||||
mustChangePassword = auth.mustChangePassword,
|
||||
readOnly = auth.readOnly,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
9
web/src/main/resources/static/robots.txt
Normal file
9
web/src/main/resources/static/robots.txt
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user