feat: закрыть остаточные P1/P2 по профилю, когорте и ведомости

- профиль приведён к docs/09: variant/level, updatedBy/name, лимит попыток
- V14: NOT NULL и полная уникальность когорты, preflight legacy-групп
- возврат группы из архива и история членства на странице группы
- пагинация групп и плоского списка материалов
- пакетные сдачи ведомости одним запросом
- XLSX-preflight импорта и audit-тесты ростера
- LDD: 66 claims, 398 тестов, release PASS
This commit is contained in:
2026-10-29 20:51:27 +03:00
parent 7ef5c866a3
commit 82394cc688
48 changed files with 1196 additions and 156 deletions

View File

@@ -10,6 +10,7 @@ import ru.ysu.lms.markdown.MarkdownRenderer
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.archiveGroup
import ru.ysu.lms.web.api.restoreGroup
import tools.jackson.databind.ObjectMapper
/** Архив группы без массового изменения статусов студентов. */
@@ -20,6 +21,21 @@ class AdminGroupArchiveController(
markdown: MarkdownRenderer,
mapper: ObjectMapper,
) : AdminSupport(api, markdown, mapper) {
@PostMapping("/{id}/restore")
fun restore(
@PathVariable id: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.restoreGroup(token(session), id)
redirect.addFlashAttribute("notice", "Группа возвращена из архива")
"redirect:/admin/groups/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups"
}
@PostMapping("/{id}/archive")
fun archive(
@PathVariable id: String,

View File

@@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.markdown.MarkdownRenderer
import ru.ysu.lms.web.DateUtils
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.CourseDetailDto
@@ -27,6 +28,7 @@ import ru.ysu.lms.web.api.GradeSetPayload
import ru.ysu.lms.web.api.GroupDetailDto
import ru.ysu.lms.web.api.LabDataDto
import ru.ysu.lms.web.api.LabSummaryDto
import ru.ysu.lms.web.api.MembershipHistoryDto
import ru.ysu.lms.web.api.PublicationSetPayload
import ru.ysu.lms.web.api.PublicationStateDto
import ru.ysu.lms.web.api.StudentCandidateDto
@@ -42,6 +44,7 @@ import ru.ysu.lms.web.api.getLabAssignments
import ru.ysu.lms.web.api.getLevels
import ru.ysu.lms.web.api.groupCandidates
import ru.ysu.lms.web.api.groupDetail
import ru.ysu.lms.web.api.groupHistory
import ru.ysu.lms.web.api.groupLabData
import ru.ysu.lms.web.api.importGroup
import ru.ysu.lms.web.api.labDetail
@@ -72,6 +75,10 @@ class AdminGroupController(
) : AdminSupport(api, markdown, mapper) {
private val log = LoggerFactory.getLogger(AdminGroupController::class.java)
private companion object {
const val GROUP_PAGE_SIZE = 50
}
@GetMapping
fun dashboard(
session: HttpSession,
@@ -101,10 +108,15 @@ class AdminGroupController(
@GetMapping("/groups")
fun groups(
@RequestParam(defaultValue = "0") page: Int,
session: HttpSession,
model: Model,
): String {
model.addAttribute("groups", api.listGroups(token(session)))
val safePage = page.coerceAtLeast(0)
val rows = api.listGroups(token(session), safePage, GROUP_PAGE_SIZE)
model.addAttribute("groups", rows)
model.addAttribute("page", safePage)
model.addAttribute("hasNext", rows.size == GROUP_PAGE_SIZE)
return "admin/groups"
}
@@ -203,7 +215,11 @@ class AdminGroupController(
val grades = api.listGrades(token)
val dataByLab = api.groupLabData(token, id, labs.map { it.id }).labs.associateBy { it.labId }
val candidates = runCatching { api.groupCandidates(token, id) }.getOrDefault(emptyList())
return groupViewModel(group, labs, grades, dataByLab, attestations, candidates)
val history =
runCatching { api.groupHistory(token, id) }
.getOrDefault(emptyList())
.onEach { it.atMsk = DateUtils.formatMsk(it.at) }
return groupViewModel(group, labs, grades, dataByLab, attestations, candidates, history)
}
/**
@@ -241,7 +257,7 @@ class AdminGroupController(
} else {
api.groupLabData(token, id, labs.map { it.id }).labs.associateBy { it.labId }
}
return groupViewModel(group, labs, grades, dataByLab, emptyList(), emptyList())
return groupViewModel(group, labs, grades, dataByLab, emptyList(), emptyList(), emptyList())
}
@Suppress("LongParameterList")
@@ -252,6 +268,7 @@ class AdminGroupController(
dataByLab: Map<String, LabDataDto>,
attestations: List<GroupAttestation>,
candidates: List<StudentCandidateDto>,
history: List<MembershipHistoryDto>,
): GroupViewModel {
val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score }
val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment }
@@ -307,6 +324,7 @@ class AdminGroupController(
labPublication,
submissionCounts,
attestations,
history,
)
}
@@ -402,6 +420,7 @@ class AdminGroupController(
val labPublication: Map<String, GroupLabPublication>,
val submissionCounts: Map<String, Int>,
val attestations: List<GroupAttestation>,
val history: List<MembershipHistoryDto>,
)
private fun fillGroupModel(
@@ -415,6 +434,7 @@ class AdminGroupController(
model.addAttribute("labPublication", view.labPublication)
model.addAttribute("labSubmissionCounts", view.submissionCounts)
model.addAttribute("attestations", view.attestations)
model.addAttribute("history", view.history)
model.addAttribute("publishedLabsCount", view.labPublication.values.count { it.publishedCount > 0 })
}

View File

@@ -35,10 +35,25 @@ fun ApiClient.contentArticle(
.body(ContentArticleDto::class.java)
}
fun ApiClient.contentTreeList(token: String?): List<ContentArticleDto> {
fun ApiClient.contentTreeList(
token: String?,
offset: Int = 0,
limit: Int = 0,
): List<ContentArticleDto> {
val type = object : ParameterizedTypeReference<List<ContentArticleDto>>() {}
return call {
val spec = client.get().uri("/api/v1/content/articles")
val spec =
client
.get()
.uri { builder ->
builder.path("/api/v1/content/articles")
if (limit > 0) {
builder
.queryParam("offset", offset.coerceAtLeast(0))
.queryParam("limit", limit.coerceIn(1, 500))
}
builder.build()
}
(if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec)
.retrieve()
.body(type)

View File

@@ -8,13 +8,24 @@ import org.springframework.util.LinkedMultiValueMap
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
fun ApiClient.listGroups(token: String): List<GroupDto> {
fun ApiClient.listGroups(
token: String,
page: Int = 0,
limit: Int = 0,
): List<GroupDto> {
val type = object : ParameterizedTypeReference<List<GroupDto>>() {}
return call {
client
.get()
.uri("/api/v1/groups")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.uri { builder ->
builder.path("/api/v1/groups")
if (limit > 0) {
builder
.queryParam("page", page.coerceAtLeast(0))
.queryParam("limit", limit.coerceIn(1, 500))
}
builder.build()
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(type)
}
@@ -165,6 +176,34 @@ fun ApiClient.archiveGroup(
.body(GroupDetailDto::class.java)
}
fun ApiClient.groupHistory(
token: String,
groupId: String,
): List<MembershipHistoryDto> {
val type = object : ParameterizedTypeReference<List<MembershipHistoryDto>>() {}
return call {
client
.get()
.uri("/api/v1/groups/{id}/history", groupId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(type)
}
}
fun ApiClient.restoreGroup(
token: String,
groupId: String,
): GroupDetailDto =
call {
client
.post()
.uri("/api/v1/groups/{id}/restore", groupId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(GroupDetailDto::class.java)
}
fun ApiClient.credentials(
token: String,
groupId: String,

View File

@@ -7,12 +7,17 @@ fun ApiClient.studentProfile(
token: String,
courseId: String,
studentId: String,
submissionLimit: Int = 50,
): StudentCourseProfileDto =
call {
client
.get()
.uri("/api/v1/courses/{courseId}/students/{studentId}/profile", courseId, studentId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.uri(
"/api/v1/courses/{courseId}/students/{studentId}/profile?submissionLimit={limit}",
courseId,
studentId,
submissionLimit.coerceIn(1, 500),
).header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(StudentCourseProfileDto::class.java)
}

View File

@@ -132,6 +132,22 @@ data class GroupDetailDto(
val students: List<StudentRowDto> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class MembershipHistoryDto(
val id: String = "",
val studentId: String = "",
val studentName: String = "",
val fromGroupId: String? = null,
val fromGroupName: String? = null,
val toGroupId: String? = null,
val toGroupName: String? = null,
val action: String = "",
val reason: String = "",
val at: String = "",
) {
var atMsk: String? = null
}
data class StudentCandidateDto(
val id: String = "",
val fullName: String = "",
@@ -760,6 +776,7 @@ data class ProfileLabDto(
val grade: ProfileGradeDto? = null,
val comments: List<ProfileCommentDto> = emptyList(),
val defense: ProfileDefenseDto? = null,
val submissionsTotal: Int = 0,
val submissions: List<ProfileSubmissionDto> = emptyList(),
) {
/** Локальные для UI значения, заполняются контроллером. */
@@ -769,12 +786,15 @@ data class ProfileLabDto(
@JsonIgnoreProperties(ignoreUnknown = true)
data class ProfileVariantDto(
val id: String = "",
val number: String = "",
val title: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ProfileLevelDto(
val id: String = "",
val code: String = "",
val title: String = "",
)
@@ -784,7 +804,7 @@ data class ProfileGradeDto(
val score: Any? = null,
val published: Boolean = false,
val generalComment: String = "",
val author: ProfileAuthorDto? = null,
val updatedBy: ProfileAuthorDto? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -825,7 +845,7 @@ data class ProfileSubmissionDto(
@JsonIgnoreProperties(ignoreUnknown = true)
data class ProfileAuthorDto(
val id: String = "",
val login: String = "",
val name: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)

View File

@@ -32,6 +32,11 @@
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary" type="submit">В архив</button>
</form>
<form class="inline-form" th:if="${group.status == 'ARCHIVED'}"
th:action="@{/admin/groups/{id}/restore(id=${group.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn" type="submit">Вернуть из архива</button>
</form>
</div>
</div>
@@ -402,6 +407,36 @@
<div class="card card-flush mt-6" th:if="${!teacherMode and !#lists.isEmpty(history)}">
<div class="card-body">
<h2 class="section-title m-0">История членства</h2>
<p class="text-sm text-muted mt-1 mb-3">Последние события по группе: зачисление, перевод, импорт, академ/возврат, повышение и архив.</p>
<div class="scroll-x">
<table class="tablekit">
<thead>
<tr><th>Когда (МСК)</th><th>Студент</th><th>Событие</th><th>Откуда → куда</th><th>Причина</th></tr>
</thead>
<tbody>
<tr th:each="event : ${history}">
<td class="text-sm" th:text="${event.atMsk ?: event.at}">—</td>
<td th:text="${event.studentName} ?: event.studentId">—</td>
<td>
<span class="badge"
th:text="${event.action == 'ENROLL' ? 'зачислен' : (event.action == 'TRANSFER' ? 'переведён' : (event.action == 'IMPORT' ? 'импорт' : (event.action == 'ACADEMIC' ? 'академ. отпуск' : (event.action == 'RETURN' ? 'возврат' : (event.action == 'GRADUATE' ? 'выпущен' : (event.action == 'EXPELL' ? 'отчислен' : (event.action == 'PROMOTE' ? 'повышен' : (event.action == 'ARCHIVE' ? 'архив' : (event.action == 'RESTORE' ? 'восстановлен' : event.action)))))))))}">—</span>
</td>
<td class="text-sm">
<span th:text="${event.fromGroupName} ?: '—'">—</span>
<span class="text-muted"> → </span>
<span th:text="${event.toGroupName} ?: '—'">—</span>
</td>
<td class="text-sm text-muted" th:text="${event.reason} ?: '—'">—</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Диалог добавления студента -->
<dialog id="modal-add-student" class="dlg" th:if="${!teacherMode}">
<div class="dlg-header">

View File

@@ -56,6 +56,15 @@
</table>
</div>
<div class="flex items-center justify-between gap-4 mt-4"
th:if="${(page ?: 0) > 0 or (hasNext ?: false)}">
<a class="btn btn-secondary" th:if="${(page ?: 0) > 0}"
th:href="@{/admin/groups(page=${(page ?: 0) - 1})}">← Назад</a>
<span class="text-sm text-muted">Страница <b th:text="${(page ?: 0) + 1}">1</b></span>
<a class="btn btn-secondary" th:if="${hasNext ?: false}"
th:href="@{/admin/groups(page=${(page ?: 0) + 1})}">Далее →</a>
</div>
<dialog id="modal-create-group" class="dlg">
<div class="dlg-header">
<h2 class="dlg-title">Новая группа</h2>

View File

@@ -129,8 +129,8 @@
<h4 class="group-title">Оценка</h4>
<p class="mb-2" th:if="${lab.grade != null}">
<span class="score-big" th:text="${lab.grade.score}">4.5</span>
<span class="text-sm text-muted" th:if="${lab.grade.author != null}"
th:text="|Автор: ${lab.grade.author.login}|"></span>
<span class="text-sm text-muted" th:if="${lab.grade.updatedBy != null}"
th:text="|Автор: ${lab.grade.updatedBy.name}|"></span>
</p>
<p class="text-muted text-sm" th:if="${lab.grade == null}">Оценка ещё не выставлена.</p>
<div class="general-comment" th:if="${lab.grade != null and !#strings.isEmpty(lab.grade.generalComment)}">
@@ -169,7 +169,7 @@
th:text="${comment.tone == 'positive' ? 'позитивный' : (comment.tone == 'negative' ? 'негативный' : 'нейтральный')}">нейтральный</span>
<span th:text="${comment.text}">—</span>
<span class="text-sm text-muted" th:if="${comment.author != null}"
th:text="|— ${comment.author.login}|"></span>
th:text="|— ${comment.author.name}|"></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>
@@ -178,6 +178,9 @@
<h4 class="group-title mt-4">Попытки сдачи</h4>
<p class="text-muted text-sm mb-0" th:if="${#lists.isEmpty(lab.submissions)}">Сдач по этой лабораторной нет.</p>
<p class="text-muted text-sm mb-2"
th:if="${lab.submissionsTotal > #lists.size(lab.submissions)}"
th:text="|Показаны последние ${#lists.size(lab.submissions)} из ${lab.submissionsTotal} попыток.|">Показаны последние попытки.</p>
<div class="card card-flush" th:if="${!#lists.isEmpty(lab.submissions)}">
<table>
<thead>

View File

@@ -15,6 +15,7 @@ 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.MembershipHistoryDto
import ru.ysu.lms.web.api.PromotionItemDto
import ru.ysu.lms.web.api.PromotionRunDetailDto
import ru.ysu.lms.web.api.PromotionRunSummaryDto
@@ -215,6 +216,55 @@ class AdminStage4TemplateTest {
assertThat(html).doesNotContain("Убрать из группы")
}
@Test
fun `renders membership history and restore action for archived group`() {
val group =
GroupDetailDto(
id = "g1",
name = "ПИ-21БО",
status = "ARCHIVED",
students = emptyList(),
)
val history =
listOf(
MembershipHistoryDto(
id = "h1",
studentId = "s1",
studentName = "Иванов Иван",
fromGroupId = "g0",
fromGroupName = "ПИ-11БО",
toGroupId = "g1",
toGroupName = "ПИ-21БО",
action = "TRANSFER",
reason = "перевод",
at = "2026-10-01T10:00:00Z",
).apply { atMsk = "01.10.2026 13:00" },
)
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,
"profileCourseNo" to null,
"teacherView" to false,
"history" to history,
)
val html = render("admin/group", model)
assertThat(html).contains("История членства")
assertThat(html).contains("Иванов Иван")
assertThat(html).contains("переведён")
assertThat(html).contains("ПИ-11БО")
assertThat(html).contains("ПИ-21БО")
assertThat(html).contains("/admin/groups/g1/restore")
}
@Test
fun `group import form requires explicit restoration flag`() {
val html = render("admin/groups", mapOf("groups" to emptyList<GroupDetailDto>()))

View File

@@ -19,16 +19,17 @@ class ProfileDtoJsonTest {
"defensesTotal": 1, "defensesReady": 0},
"labs": [{
"id": "l1", "number": 1, "title": "ЛР", "deadlineAt": "2026-10-01T20:59:00Z",
"variant": {"number": "3", "title": "В-3"},
"level": {"title": "Базовый"},
"variant": {"id": "v1", "number": "3", "title": "В-3"},
"level": {"id": "lv1", "code": "base", "title": "Базовый"},
"attemptsCount": 1,
"lastSubmissionAt": "2026-09-28T18:12:00Z",
"grade": {"score": "4.5", "published": true, "generalComment": "Ок",
"author": {"id": "t1", "login": "petrov"}},
"updatedBy": {"id": "t1", "name": "Петров Пётр"}},
"comments": [{"id": "c1", "text": "Комментарий", "tone": "neutral",
"author": {"id": "t1", "login": "petrov"}, "createdAt": "2026-09-29T09:30:00Z"}],
"author": {"id": "t1", "name": "Петров Пётр"}, "createdAt": "2026-09-29T09:30:00Z"}],
"defense": {"sessionId": "d1", "status": "ready", "readyReason": "timeout",
"code": null, "assignmentMd": "Задание", "secondsLeft": null},
"submissionsTotal": 1,
"submissions": [{"id": "sub1", "seq": 1, "createdAt": "2026-09-28T18:12:00Z",
"files": ["main.c"], "downloadUrl": "/download"}]
}]
@@ -39,8 +40,13 @@ class ProfileDtoJsonTest {
assertThat(profile.student.fullName).isEqualTo("Иванов")
assertThat(profile.summary.averageScore).isEqualTo(4.5)
assertThat(profile.labs).hasSize(1)
assertThat(profile.labs[0].grade?.score).isEqualTo("4.5")
assertThat(profile.labs[0].submissions[0].files).containsExactly("main.c")
val lab = profile.labs.single()
assertThat(lab.grade?.score).isEqualTo("4.5")
assertThat(lab.grade?.updatedBy?.name).isEqualTo("Петров Пётр")
assertThat(lab.variant?.id).isEqualTo("v1")
assertThat(lab.level?.code).isEqualTo("base")
assertThat(lab.submissionsTotal).isEqualTo(1)
assertThat(lab.submissions[0].files).containsExactly("main.c")
}
@Test

View File

@@ -48,6 +48,7 @@ class StudentProfileTemplateTest {
assertThat(html).contains("попыток: 2")
assertThat(html).contains("4.5")
assertThat(html).contains("Хорошо")
assertThat(html).contains("Петров Пётр")
assertThat(html).contains("Разделите функцию на части")
assertThat(html).contains("кончилось время")
assertThat(html).contains("main.c")
@@ -128,8 +129,8 @@ class StudentProfileTemplateTest {
number = 1,
title = "Введение в C",
deadlineAt = "2026-10-01T20:59:00Z",
variant = ProfileVariantDto(number = "3", title = "Вариант 3"),
level = ProfileLevelDto(title = "Базовый"),
variant = ProfileVariantDto(id = "v1", number = "3", title = "Вариант 3"),
level = ProfileLevelDto(id = "lv1", code = "base", title = "Базовый"),
attemptsCount = 2,
lastSubmissionAt = "2026-09-28T18:12:00Z",
grade = sampleGrade(),
@@ -146,7 +147,7 @@ class StudentProfileTemplateTest {
score = "4.5",
published = true,
generalComment = "Хорошо",
author = ProfileAuthorDto(id = "t1", login = "petrov"),
updatedBy = ProfileAuthorDto(id = "t1", name = "Петров Пётр"),
)
private fun sampleComment(): ProfileCommentDto =
@@ -154,7 +155,7 @@ class StudentProfileTemplateTest {
id = "c1",
text = "Разделите функцию на части",
tone = "neutral",
author = ProfileAuthorDto(id = "t1", login = "petrov"),
author = ProfileAuthorDto(id = "t1", name = "Петров Пётр"),
createdAt = "2026-09-29T09:30:00Z",
).apply { createdAtMsk = "29.09.2026 12:30" }