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:
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>()))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user