feat(web): повышение курса и жизненный цикл студентов

This commit is contained in:
2026-10-27 18:42:02 +03:00
parent e139a585b4
commit 1d21e66dc4
16 changed files with 1387 additions and 6 deletions

View File

@@ -0,0 +1,37 @@
package ru.ysu.lms.web.admin
import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.servlet.mvc.support.RedirectAttributes
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 tools.jackson.databind.ObjectMapper
/** Архив группы без массового изменения статусов студентов. */
@Controller
@RequestMapping("/admin/groups")
class AdminGroupArchiveController(
api: ApiClient,
markdown: MarkdownRenderer,
mapper: ObjectMapper,
) : AdminSupport(api, markdown, mapper) {
@PostMapping("/{id}/archive")
fun archive(
@PathVariable id: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.archiveGroup(token(session), id)
redirect.addFlashAttribute("notice", "Группа перемещена в архив")
"redirect:/admin/groups"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups"
}
}

View File

@@ -0,0 +1,115 @@
package ru.ysu.lms.web.admin
import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.servlet.mvc.support.RedirectAttributes
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.PromotionDryRunPayload
import ru.ysu.lms.web.api.applyPromotion
import ru.ysu.lms.web.api.cancelPromotion
import ru.ysu.lms.web.api.dryRunPromotion
import ru.ysu.lms.web.api.listPromotionRuns
import ru.ysu.lms.web.api.promotionRunDetail
import ru.ysu.lms.web.api.rollbackPromotion
import tools.jackson.databind.ObjectMapper
@Controller
@RequestMapping("/admin/promotion")
class AdminPromotionController(
api: ApiClient,
markdown: MarkdownRenderer,
mapper: ObjectMapper,
) : AdminSupport(api, markdown, mapper) {
@GetMapping
fun list(
session: HttpSession,
model: Model,
): String {
model.addAttribute("runs", PromotionViews.list(api.listPromotionRuns(token(session))))
return "admin/promotion"
}
@PostMapping("/dry-run")
fun dryRun(
@RequestParam(required = false) year: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val parsedYear = year?.trim()?.takeIf { it.isNotEmpty() }
if (parsedYear != null && parsedYear.toIntOrNull() == null) {
redirect.addFlashAttribute("error", "Год повышения должен быть числом")
return "redirect:/admin/promotion"
}
return try {
val run = api.dryRunPromotion(token(session), PromotionDryRunPayload(parsedYear?.toInt()))
redirect.addFlashAttribute("notice", "План повышения построен")
"redirect:/admin/promotion/${run.id}"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/promotion"
}
}
@GetMapping("/{id}")
fun detail(
@PathVariable id: String,
session: HttpSession,
model: Model,
): String {
model.addAttribute("run", PromotionViews.detail(api.promotionRunDetail(token(session), id)))
return "admin/promotion-detail"
}
@PostMapping("/{id}/apply")
fun apply(
@PathVariable id: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.applyPromotion(token(session), id)
redirect.addFlashAttribute("notice", "Повышение применено")
"redirect:/admin/promotion/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/promotion/$id"
}
@PostMapping("/{id}/rollback")
fun rollback(
@PathVariable id: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.rollbackPromotion(token(session), id)
redirect.addFlashAttribute("notice", "Повышение откатано")
"redirect:/admin/promotion/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/promotion/$id"
}
@PostMapping("/{id}/cancel")
fun cancel(
@PathVariable id: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.cancelPromotion(token(session), id)
redirect.addFlashAttribute("notice", "План повышения отменён")
"redirect:/admin/promotion/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/promotion/$id"
}
}

View File

@@ -0,0 +1,111 @@
package ru.ysu.lms.web.admin
import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.servlet.mvc.support.RedirectAttributes
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.StudentLifecycleResultDto
import ru.ysu.lms.web.api.studentAcademicLeave
import ru.ysu.lms.web.api.studentArchive
import ru.ysu.lms.web.api.studentExpel
import ru.ysu.lms.web.api.studentRestore
import ru.ysu.lms.web.api.studentReturn
import tools.jackson.databind.ObjectMapper
/** Действия жизненного цикла студента из ведомости группы. */
@Controller
@RequestMapping("/admin/groups/{groupId}/students/{studentId}")
class AdminStudentLifecycleController(
api: ApiClient,
markdown: MarkdownRenderer,
mapper: ObjectMapper,
) : AdminSupport(api, markdown, mapper) {
@PostMapping("/academic-leave")
fun academicLeave(
@PathVariable groupId: String,
@PathVariable studentId: String,
@RequestParam(required = false) reason: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
perform(groupId, redirect) {
val result = api.studentAcademicLeave(token(session), studentId, reason)
"Студент ${result.fullName} переведён в академотпуск${groupSuffix(result)}"
}
@PostMapping("/return")
fun returnToGroup(
@PathVariable groupId: String,
@PathVariable studentId: String,
@RequestParam(required = false) reason: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
perform(groupId, redirect) {
val result = api.studentReturn(token(session), studentId, groupId, reason)
"Студент ${result.fullName} возвращён в группу ${result.toGroupName ?: groupId}"
}
@PostMapping("/expel")
fun expel(
@PathVariable groupId: String,
@PathVariable studentId: String,
@RequestParam(required = false) reason: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
perform(groupId, redirect) {
val result = api.studentExpel(token(session), studentId, reason)
"Студент ${result.fullName} отчислен"
}
@PostMapping("/archive")
fun archive(
@PathVariable groupId: String,
@PathVariable studentId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String =
perform(groupId, redirect) {
val result = api.studentArchive(token(session), studentId)
"Студент ${result.fullName} перемещён в архив"
}
@PostMapping("/restore")
fun restore(
@PathVariable groupId: String,
@PathVariable studentId: String,
@RequestParam(required = false) reason: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
perform(groupId, redirect) {
val result = api.studentRestore(token(session), studentId, groupId, reason)
"Студент ${result.fullName} восстановлен в группе ${result.toGroupName ?: groupId}"
}
private fun perform(
groupId: String,
redirect: RedirectAttributes,
action: () -> String,
): String =
try {
redirect.addFlashAttribute("notice", action())
"redirect:/admin/groups/$groupId"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups/$groupId"
}
private fun groupSuffix(result: StudentLifecycleResultDto): String =
result.toGroupName
?.takeIf { it.isNotBlank() }
?.let { " ($it)" }
.orEmpty()
}

View File

@@ -42,6 +42,7 @@ class AdminUsersController(
@PostMapping("/{id}/delete") @PostMapping("/{id}/delete")
fun delete( fun delete(
@PathVariable id: String, @PathVariable id: String,
@RequestParam(required = false, name = "confirm") confirm: String?,
@RequestParam(required = false, name = "q") query: String?, @RequestParam(required = false, name = "q") query: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
@@ -51,6 +52,10 @@ class AdminUsersController(
redirect.addFlashAttribute("error", "Нельзя удалить собственный аккаунт") redirect.addFlashAttribute("error", "Нельзя удалить собственный аккаунт")
return redirectTo(query) return redirectTo(query)
} }
if (confirm.isNullOrBlank()) {
redirect.addFlashAttribute("error", "Чтобы удалить пользователя, введите его логин или ФИО")
return redirectTo(query)
}
return try { return try {
val t = token(session) val t = token(session)
val student = api.searchStudents(t, null).firstOrNull { it.id == id } val student = api.searchStudents(t, null).firstOrNull { it.id == id }
@@ -58,7 +63,7 @@ class AdminUsersController(
// данные-сироты (дочищаются повторным запуском), а не «живой» аккаунт без данных. // данные-сироты (дочищаются повторным запуском), а не «живой» аккаунт без данных.
val deleted = val deleted =
try { try {
api.deleteStudent(t, id) api.deleteStudent(t, id, confirm)
} catch (alreadyGone: ApiException) { } catch (alreadyGone: ApiException) {
if (alreadyGone.status != 404) throw alreadyGone if (alreadyGone.status != 404) throw alreadyGone
null // аккаунта уже нет — дочищаем данные после прошлого сбоя null // аккаунта уже нет — дочищаем данные после прошлого сбоя

View File

@@ -0,0 +1,271 @@
package ru.ysu.lms.web.admin
import ru.ysu.lms.web.DateUtils
import ru.ysu.lms.web.api.PromotionItemDto
import ru.ysu.lms.web.api.PromotionRunDetailDto
import ru.ysu.lms.web.api.PromotionRunSummaryDto
data class PromotionCount(
val label: String,
val value: Int,
)
data class PromotionStateField(
val label: String,
val value: String,
)
data class PromotionRunRow(
val id: String,
val year: Int,
val mode: String,
val modeLabel: String,
val status: String,
val statusLabel: String,
val statusBadgeClass: String,
val startedMsk: String?,
val finishedMsk: String?,
val counts: List<PromotionCount>,
val warnings: List<String>,
)
data class PromotionItemRow(
val id: String,
val entityType: String,
val entityTypeLabel: String,
val entityId: String,
val entityLabel: String,
val action: String,
val actionLabel: String,
val status: String,
val statusLabel: String,
val statusBadgeClass: String,
val error: String,
val before: List<PromotionStateField>,
val after: List<PromotionStateField>,
)
data class PromotionRunView(
val id: String,
val year: Int,
val mode: String,
val modeLabel: String,
val status: String,
val statusLabel: String,
val statusBadgeClass: String,
val startedMsk: String?,
val finishedMsk: String?,
val generatedMsk: String?,
val appliedMsk: String?,
val rolledBackMsk: String?,
val counts: List<PromotionCount>,
val warnings: List<String>,
val items: List<PromotionItemRow>,
val canApply: Boolean,
val canCancel: Boolean,
val canRollback: Boolean,
val canRebuild: Boolean,
)
/** Представление журнала повышения для шаблонов админки. */
object PromotionViews {
private val COUNT_LABELS =
listOf(
"groupsPromote" to "перевод групп",
"groupsGraduate" to "выпуск групп",
"studentsGraduate" to "выпуск студентов",
"studentsAcademic" to "академ",
)
private val GROUP_FIELDS = listOf("name", "course", "status", "stream", "number", "degree", "enrollmentYear")
private val STUDENT_FIELDS = listOf("fullName", "studentNo", "status", "enrollmentYear", "groupName")
fun list(runs: List<PromotionRunSummaryDto>): List<PromotionRunRow> = runs.map(::summary)
fun detail(run: PromotionRunDetailDto): PromotionRunView {
val items = run.items.map(::item)
val result = run.result
return PromotionRunView(
id = run.id,
year = run.year,
mode = run.mode,
modeLabel = modeLabel(run.mode),
status = run.status,
statusLabel = runStatusLabel(run.status),
statusBadgeClass = badgeClass(run.status == "COMPLETED"),
startedMsk = DateUtils.formatMsk(run.startedAt),
finishedMsk = DateUtils.formatMsk(run.finishedAt),
generatedMsk = DateUtils.formatMsk(result?.get("generatedAt")?.toString()),
appliedMsk = DateUtils.formatMsk(result?.get("appliedAt")?.toString()),
rolledBackMsk = DateUtils.formatMsk(result?.get("rolledBackAt")?.toString()),
counts = counts(result, run.items),
warnings = warnings(result),
items = items,
canApply = run.status == "PLANNED" && run.mode == "DRY_RUN",
canCancel = run.status == "PLANNED" && run.mode == "DRY_RUN",
canRollback = run.status == "COMPLETED",
canRebuild = run.status == "FAILED" || run.status == "CANCELLED",
)
}
private fun summary(run: PromotionRunSummaryDto): PromotionRunRow =
PromotionRunRow(
id = run.id,
year = run.year,
mode = run.mode,
modeLabel = modeLabel(run.mode),
status = run.status,
statusLabel = runStatusLabel(run.status),
statusBadgeClass = badgeClass(run.status == "COMPLETED"),
startedMsk = DateUtils.formatMsk(run.startedAt),
finishedMsk = DateUtils.formatMsk(run.finishedAt),
counts = counts(run.result, emptyList()),
warnings = warnings(run.result),
)
private fun item(item: PromotionItemDto): PromotionItemRow =
PromotionItemRow(
id = item.id,
entityType = item.entityType,
entityTypeLabel = entityTypeLabel(item.entityType),
entityId = item.entityId,
entityLabel = entityLabel(item),
action = item.action,
actionLabel = actionLabel(item.action),
status = item.status,
statusLabel = itemStatusLabel(item.status),
statusBadgeClass = badgeClass(item.status == "APPLIED"),
error = item.error,
before = stateFields(item.entityType, item.before),
after = stateFields(item.entityType, item.after),
)
private fun entityLabel(item: PromotionItemDto): String =
item.before.text("fullName")
?: item.before.text("name")
?: item.after.text("fullName")
?: item.after.text("name")
?: item.entityId
private fun counts(
result: Map<String, Any?>?,
items: List<PromotionItemDto>,
): List<PromotionCount> {
val raw = result?.get("counts") as? Map<*, *>
if (raw != null) {
return COUNT_LABELS.map { (key, label) ->
PromotionCount(label, (raw[key] as? Number)?.toInt() ?: 0)
}
}
val byAction = items.groupingBy { it.entityType to it.action }.eachCount()
return listOf(
PromotionCount("перевод групп", byAction["GROUP" to "PROMOTE"] ?: 0),
PromotionCount("выпуск групп", byAction["GROUP" to "GRADUATE"] ?: 0),
PromotionCount("выпуск студентов", byAction["STUDENT" to "GRADUATE"] ?: 0),
PromotionCount("академ", byAction["STUDENT" to "ACADEMIC"] ?: 0),
)
}
private fun warnings(result: Map<String, Any?>?): List<String> =
(result?.get("warnings") as? List<*>)?.mapNotNull { it?.toString()?.takeIf { text -> text.isNotBlank() } }.orEmpty()
private fun stateFields(
entityType: String,
state: Map<String, Any?>,
): List<PromotionStateField> =
buildList {
val fields = if (entityType == "GROUP") GROUP_FIELDS else STUDENT_FIELDS
fields.forEach { key ->
state[key]?.let { add(PromotionStateField(fieldLabel(key), fieldValue(entityType, key, it))) }
}
(state["targetGroup"] as? Map<*, *>)?.get("name")?.let {
add(PromotionStateField("целевая группа", it.toString()))
}
}
private fun fieldLabel(key: String): String =
when (key) {
"name" -> "название"
"fullName" -> "ФИО"
"studentNo" -> "№"
"course" -> "курс"
"status" -> "статус"
"stream" -> "поток"
"number" -> "номер"
"degree" -> "степень"
"enrollmentYear" -> "год набора"
"groupName" -> "группа"
else -> key
}
private fun fieldValue(
entityType: String,
key: String,
value: Any,
): String =
when {
key == "status" -> statusLabel(entityType, value.toString())
value is Boolean -> if (value) "да" else "нет"
else -> value.toString()
}
private fun statusLabel(
entityType: String,
status: String,
): String =
when (status) {
"ACTIVE" -> if (entityType == "GROUP") "активна" else "активен"
"ACADEMIC_LEAVE" -> "академ"
"EXPELLED" -> "отчислен"
"GRADUATED" -> if (entityType == "GROUP") "выпущена" else "выпущен"
"ARCHIVED" -> "архив"
else -> status
}
private fun modeLabel(mode: String): String =
when (mode) {
"DRY_RUN" -> "Пробный"
"APPLY" -> "Применение"
else -> mode
}
private fun runStatusLabel(status: String): String =
when (status) {
"PLANNED" -> "Запланирован"
"RUNNING" -> "Выполняется"
"COMPLETED" -> "Завершён"
"FAILED" -> "Ошибка"
"CANCELLED" -> "Отменён"
"ROLLED_BACK" -> "Откатан"
else -> status
}
private fun itemStatusLabel(status: String): String =
when (status) {
"PLANNED" -> "Запланировано"
"APPLIED" -> "Применено"
"SKIPPED" -> "Пропущено"
"ERROR" -> "Ошибка"
"ROLLED_BACK" -> "Откатано"
else -> status
}
private fun entityTypeLabel(entityType: String): String =
when (entityType) {
"GROUP" -> "Группа"
"STUDENT" -> "Студент"
else -> entityType
}
private fun actionLabel(action: String): String =
when (action) {
"PROMOTE" -> "Перевод на следующий курс"
"GRADUATE" -> "Выпуск"
"ACADEMIC" -> "Академический отпуск"
else -> action
}
private fun badgeClass(ok: Boolean): String = if (ok) "badge-ok" else "badge-warn"
private fun Map<String, Any?>.text(key: String): String? = (this[key] as? String)?.takeIf { it.isNotBlank() }
}

View File

@@ -150,16 +150,31 @@ fun ApiClient.searchStudents(
fun ApiClient.deleteStudent( fun ApiClient.deleteStudent(
token: String, token: String,
studentId: String, studentId: String,
confirm: String,
): DeleteUserDto = ): DeleteUserDto =
call { call {
client client
.delete() .delete()
.uri("/api/v1/students/{id}", studentId) .uri { builder ->
.header(HttpHeaders.AUTHORIZATION, bearer(token)) builder.path("/api/v1/students/{id}").queryParam("confirm", confirm).build(studentId)
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve() .retrieve()
.body(DeleteUserDto::class.java) .body(DeleteUserDto::class.java)
} }
fun ApiClient.archiveGroup(
token: String,
groupId: String,
): GroupDetailDto =
call {
client
.post()
.uri("/api/v1/groups/{id}/archive", groupId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(GroupDetailDto::class.java)
}
fun ApiClient.credentials( fun ApiClient.credentials(
token: String, token: String,
groupId: String, groupId: String,

View File

@@ -0,0 +1,84 @@
package ru.ysu.lms.web.api
import org.springframework.core.ParameterizedTypeReference
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
fun ApiClient.listPromotionRuns(token: String): List<PromotionRunSummaryDto> {
val type = object : ParameterizedTypeReference<List<PromotionRunSummaryDto>>() {}
return call {
client
.get()
.uri("/api/v1/promotion")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(type)
}
}
fun ApiClient.promotionRunDetail(
token: String,
runId: String,
): PromotionRunDetailDto =
call {
client
.get()
.uri("/api/v1/promotion/{id}", runId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(PromotionRunDetailDto::class.java)
}
fun ApiClient.dryRunPromotion(
token: String,
payload: PromotionDryRunPayload,
): PromotionRunDetailDto =
call {
client
.post()
.uri("/api/v1/promotion/dry-run")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.body(PromotionRunDetailDto::class.java)
}
fun ApiClient.applyPromotion(
token: String,
runId: String,
): PromotionRunDetailDto =
call {
client
.post()
.uri("/api/v1/promotion/{id}/apply", runId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(PromotionRunDetailDto::class.java)
}
fun ApiClient.cancelPromotion(
token: String,
runId: String,
): PromotionRunDetailDto =
call {
client
.post()
.uri("/api/v1/promotion/{id}/cancel", runId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(PromotionRunDetailDto::class.java)
}
fun ApiClient.rollbackPromotion(
token: String,
runId: String,
): PromotionRunDetailDto =
call {
client
.post()
.uri("/api/v1/promotion/{id}/rollback", runId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(PromotionRunDetailDto::class.java)
}

View File

@@ -0,0 +1,83 @@
package ru.ysu.lms.web.api
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
fun ApiClient.studentAcademicLeave(
token: String,
studentId: String,
reason: String? = null,
): StudentLifecycleResultDto =
call {
client
.post()
.uri("/api/v1/students/{id}/academic-leave", studentId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(StudentReasonPayload(reason))
.retrieve()
.body(StudentLifecycleResultDto::class.java)
}
fun ApiClient.studentReturn(
token: String,
studentId: String,
groupId: String,
reason: String? = null,
): StudentLifecycleResultDto =
call {
client
.post()
.uri("/api/v1/students/{id}/return", studentId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(StudentGroupPayload(groupId, reason))
.retrieve()
.body(StudentLifecycleResultDto::class.java)
}
fun ApiClient.studentExpel(
token: String,
studentId: String,
reason: String? = null,
): StudentLifecycleResultDto =
call {
client
.post()
.uri("/api/v1/students/{id}/expel", studentId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(StudentReasonPayload(reason))
.retrieve()
.body(StudentLifecycleResultDto::class.java)
}
fun ApiClient.studentArchive(
token: String,
studentId: String,
): StudentLifecycleResultDto =
call {
client
.post()
.uri("/api/v1/students/{id}/archive", studentId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(StudentLifecycleResultDto::class.java)
}
fun ApiClient.studentRestore(
token: String,
studentId: String,
groupId: String,
reason: String? = null,
): StudentLifecycleResultDto =
call {
client
.post()
.uri("/api/v1/students/{id}/restore", studentId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(StudentGroupPayload(groupId, reason))
.retrieve()
.body(StudentLifecycleResultDto::class.java)
}

View File

@@ -795,3 +795,67 @@ data class ProfileAuthorDto(
val id: String = "", val id: String = "",
val login: String = "", val login: String = "",
) )
@JsonIgnoreProperties(ignoreUnknown = true)
data class PromotionRunSummaryDto(
val id: String = "",
val year: Int = 0,
val mode: String = "DRY_RUN",
val status: String = "",
val actorAccountId: String? = null,
val startedAt: String = "",
val finishedAt: String? = null,
val result: Map<String, Any?>? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class PromotionItemDto(
val id: String = "",
val entityType: String = "",
val entityId: String = "",
val action: String = "",
val status: String = "",
val error: String = "",
val createdAt: String? = null,
val before: Map<String, Any?> = emptyMap(),
val after: Map<String, Any?> = emptyMap(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class PromotionRunDetailDto(
val id: String = "",
val year: Int = 0,
val mode: String = "DRY_RUN",
val status: String = "",
val actorAccountId: String? = null,
val startedAt: String = "",
val finishedAt: String? = null,
val result: Map<String, Any?>? = null,
val items: List<PromotionItemDto> = emptyList(),
)
data class PromotionDryRunPayload(
val year: Int? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class StudentLifecycleResultDto(
val studentId: String = "",
val fullName: String = "",
val status: String = "",
val action: String = "",
val fromGroupId: String? = null,
val fromGroupName: String? = null,
val toGroupId: String? = null,
val toGroupName: String? = null,
val enrollmentYear: Short? = null,
)
data class StudentReasonPayload(
val reason: String? = null,
)
data class StudentGroupPayload(
val groupId: String,
val reason: String? = null,
)

View File

@@ -26,6 +26,12 @@
</a> </a>
<button class="btn btn-secondary" type="button" data-modal="modal-add-attestation">+ Аттестация</button> <button class="btn btn-secondary" type="button" data-modal="modal-add-attestation">+ Аттестация</button>
<button class="btn" type="button" data-modal="modal-add-student">+ Студент</button> <button class="btn" type="button" data-modal="modal-add-student">+ Студент</button>
<form class="inline-form" th:if="${group.status != 'ARCHIVED'}"
th:action="@{/admin/groups/{id}/archive(id=${group.id})}" method="post"
data-confirm="Переместить группу в архив? Студенты и данные сохранятся, группа станет неактивной.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary" type="submit">В архив</button>
</form>
</div> </div>
</div> </div>
@@ -195,11 +201,52 @@
<button class="btn btn-secondary btn-sm" type="submit" <button class="btn btn-secondary btn-sm" type="submit"
title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить</button> title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить</button>
</form> </form>
<form th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}"
method="post" data-confirm="Отчислить этого студента из группы? Аккаунт, сдача и оценки сохранятся, но вход в систему будет закрыт."> <form th:if="${row.status == 'ACTIVE'}"
th:action="@{/admin/groups/{gid}/students/{sid}/academic-leave(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">Академ</button>
</form>
<form th:if="${row.status == 'ACTIVE'}"
th:action="@{/admin/groups/{gid}/students/{sid}/archive(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">В архив</button>
</form>
<form th:if="${row.status == 'ACTIVE'}"
th:action="@{/admin/groups/{gid}/students/{sid}/expel(gid=${group.id}, sid=${row.studentId})}"
method="post"
data-confirm="Отчислить студента? Зачисление и история сохранятся, вход в систему будет закрыт.">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-sm btn-danger" type="submit">Отчислить</button> <button class="btn btn-sm btn-danger" type="submit">Отчислить</button>
</form> </form>
<form th:if="${row.status == 'ACADEMIC_LEAVE'}"
th:action="@{/admin/groups/{gid}/students/{sid}/return(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">Вернуть в группу</button>
</form>
<form th:if="${row.status == 'ARCHIVED' or row.status == 'EXPELLED'}"
th:action="@{/admin/groups/{gid}/students/{sid}/restore(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">Восстановить в группу</button>
</form>
<form th:if="${row.status == 'ACTIVE'}"
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> </div>
</td> </td>
</tr> </tr>

View File

@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title th:text="|Повышение ${run.year} — LMS|">Повышение</title></head>
<body>
<div id="content">
<a th:href="@{/admin/promotion}" class="nav-link">← Все запуски</a>
<div class="page-head">
<div>
<p class="kicker">Журнал повышения</p>
<h1 class="page-title" th:text="|Повышение ${run.year}|">Повышение</h1>
<p class="meta">
<span class="badge" th:classappend="${run.statusBadgeClass}" th:text="${run.statusLabel}">—</span>
<span class="badge" th:text="${run.modeLabel}">Пробный</span>
<span th:if="${run.startedMsk}">Начат: <b th:text="${run.startedMsk}">—</b></span>
<span th:if="${run.finishedMsk}"> · Завершён: <b th:text="${run.finishedMsk}">—</b></span>
</p>
</div>
<div class="actions">
<form class="inline-form" th:if="${run.canApply}"
th:action="@{/admin/promotion/{id}/apply(id=${run.id})}" method="post"
data-confirm="Применить повышение? Изменятся курсы и названия групп, выпускники и академ-переводы получат новые статусы.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-primary" type="submit">Применить план</button>
</form>
<form class="inline-form" th:if="${run.canCancel}"
th:action="@{/admin/promotion/{id}/cancel(id=${run.id})}" method="post"
data-confirm="Отменить пробный план повышения? Он не будет применён, после отмены можно построить новый план на этот год.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary" type="submit">Отменить план</button>
</form>
<form class="inline-form" th:if="${run.canRollback}"
th:action="@{/admin/promotion/{id}/rollback(id=${run.id})}" method="post"
data-confirm="Откатить повышение? Группы и студенты вернутся к состоянию до применения плана.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-danger" type="submit">Откатить</button>
</form>
<form class="inline-form" th:if="${run.canRebuild}"
th:action="@{/admin/promotion/dry-run}" method="post"
data-confirm="Построить новый пробный план на этот год?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="year" th:value="${run.year}">
<button class="btn btn-primary" type="submit">Перестроить план</button>
</form>
</div>
</div>
<div class="card card-body mb-6">
<div class="flex flex-wrap gap-2 mb-4">
<span class="badge" th:each="count : ${run.counts}"
th:text="|${count.label}: ${count.value}|">—</span>
</div>
<div class="flex flex-wrap gap-4 text-sm text-muted">
<span th:if="${run.generatedMsk}">План построен: <b th:text="${run.generatedMsk}">—</b></span>
<span th:if="${run.appliedMsk}">Применён: <b th:text="${run.appliedMsk}">—</b></span>
<span th:if="${run.rolledBackMsk}">Откатан: <b th:text="${run.rolledBackMsk}">—</b></span>
</div>
<p class="hint" th:if="${run.canRebuild}">
План отменён или завершился ошибкой. Постройте новый пробный план на этот же год.
</p>
</div>
<div class="card card-body mb-6" th:if="${!#lists.isEmpty(run.warnings)}">
<h2 class="section-title">Предупреждения</h2>
<ul class="text-sm">
<li th:each="warning : ${run.warnings}" th:text="${warning}">—</li>
</ul>
<p class="hint">Эти группы или студенты пропущены при построении плана.</p>
</div>
<div class="card card-flush">
<div class="scroll-x">
<table class="tablekit">
<thead>
<tr>
<th>Сущность</th>
<th>Действие</th>
<th>Статус</th>
<th>Было</th>
<th>Стало</th>
<th>Ошибка</th>
</tr>
</thead>
<tbody>
<tr th:each="item : ${run.items}">
<td>
<div class="font-medium" th:text="${item.entityLabel}">—</div>
<div class="text-sm text-muted">
<span th:text="${item.entityTypeLabel}">Группа</span>
<span class="font-mono" th:text="${item.entityId}">—</span>
</div>
</td>
<td th:text="${item.actionLabel}">—</td>
<td>
<span class="badge" th:classappend="${item.statusBadgeClass}" th:text="${item.statusLabel}">—</span>
</td>
<td class="text-sm">
<div th:each="field : ${item.before}">
<span class="text-muted" th:text="${field.label} + ':'">—</span>
<span th:text="${field.value}">—</span>
</div>
<span class="text-muted" th:if="${#lists.isEmpty(item.before)}">—</span>
</td>
<td class="text-sm">
<div th:each="field : ${item.after}">
<span class="text-muted" th:text="${field.label} + ':'">—</span>
<span th:text="${field.value}">—</span>
</div>
<span class="text-muted" th:if="${#lists.isEmpty(item.after)}">—</span>
</td>
<td class="text-sm">
<span th:if="${!#strings.isEmpty(item.error)}" th:text="${item.error}">—</span>
<span class="text-muted" th:if="${#strings.isEmpty(item.error)}">—</span>
</td>
</tr>
<tr th:if="${#lists.isEmpty(run.items)}">
<td colspan="6" class="empty">В плане нет элементов.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title>Повышение — LMS</title></head>
<body>
<div id="content">
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div>
<h1 class="page-title m-0">Повышение</h1>
<p class="meta">Планы перевода групп и студентов на следующий учебный год.</p>
</div>
</div>
<form class="card card-body mb-6" th:action="@{/admin/promotion/dry-run}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<h2 class="section-title">Построить пробный план</h2>
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="label" for="promotion-year">Учебный год</label>
<input class="input" id="promotion-year" name="year" type="number" min="2000" max="2100"
placeholder="Текущий год" aria-describedby="promotion-year-hint">
</div>
<button class="btn btn-primary" type="submit">Dry-run</button>
</div>
<p class="hint" id="promotion-year-hint">
Если год не указан, используется текущий. Пробный план ничего не меняет — его можно проверить и применить.
</p>
</form>
<div class="card card-flush">
<table class="tablekit">
<thead>
<tr>
<th>Год</th>
<th>Режим</th>
<th>Статус</th>
<th>Начало</th>
<th>Завершение</th>
<th>Состав плана</th>
<th data-nosort></th>
</tr>
</thead>
<tbody>
<tr th:each="run : ${runs}">
<td class="font-medium" th:text="${run.year}">2026</td>
<td th:text="${run.modeLabel}">Пробный</td>
<td>
<span class="badge" th:classappend="${run.statusBadgeClass}" th:text="${run.statusLabel}">—</span>
</td>
<td class="text-muted" th:text="${run.startedMsk} ?: '—'">—</td>
<td class="text-muted" th:text="${run.finishedMsk} ?: '—'">—</td>
<td>
<div class="flex flex-wrap gap-1">
<span class="badge" th:each="count : ${run.counts}"
th:text="|${count.label}: ${count.value}|">—</span>
<span class="badge badge-warn" th:if="${!#lists.isEmpty(run.warnings)}"
th:text="|предупреждений: ${#lists.size(run.warnings)}|">—</span>
</div>
</td>
<td class="text-right">
<a class="btn btn-secondary btn-sm" th:href="@{/admin/promotion/{id}(id=${run.id})}">Открыть</a>
</td>
</tr>
<tr th:if="${#lists.isEmpty(runs)}">
<td colspan="7" class="empty">Запусков повышения ещё не было.</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -33,11 +33,15 @@
th:text="${u.status == 'ACTIVE' ? 'активен' : (u.status == 'EXPELLED' ? 'отчислен' : (u.status == 'ACADEMIC_LEAVE' ? 'академ' : (u.status == 'GRADUATED' ? 'выпущен' : 'архив')))}">—</span> th:text="${u.status == 'ACTIVE' ? 'активен' : (u.status == 'EXPELLED' ? 'отчислен' : (u.status == 'ACADEMIC_LEAVE' ? 'академ' : (u.status == 'GRADUATED' ? 'выпущен' : 'архив')))}">—</span>
</td> </td>
<td class="text-right"> <td class="text-right">
<form class="inline-form" th:action="@{/admin/users/{id}/delete(id=${u.id})}" method="post" data-confirm="Удалить пользователя безвозвратно? Будут удалены аккаунт, группа, оценки с комментариями, сдачи с файлами и задания защит."> <form class="inline-form" th:action="@{/admin/users/{id}/delete(id=${u.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="q" th:value="${query}"> <input type="hidden" name="q" th:value="${query}">
<input class="input" name="confirm" size="16" required autocomplete="off"
placeholder="Логин или ФИО" aria-label="Логин или ФИО для удаления"
title="Введите логин или ФИО студента, чтобы подтвердить удаление">
<button class="btn btn-danger" type="submit">Удалить</button> <button class="btn btn-danger" type="submit">Удалить</button>
</form> </form>
<p class="hint">Введите логин или ФИО для подтверждения. Удаляются аккаунт и все данные.</p>
</td> </td>
</tr> </tr>
<tr th:if="${#lists.isEmpty(users)}"><td colspan="5" class="empty">Никого не нашли.</td></tr> <tr th:if="${#lists.isEmpty(users)}"><td colspan="5" class="empty">Никого не нашли.</td></tr>

View File

@@ -49,6 +49,7 @@
<div class="offcanvas-body"> <div class="offcanvas-body">
<ul class="navbar-nav me-auto mb-2 mb-md-0"> <ul class="navbar-nav me-auto mb-2 mb-md-0">
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/groups}">Группы</a></li> <li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/groups}">Группы</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/promotion}">Повышение</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/courses}">Курсы</a></li> <li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/courses}">Курсы</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/users}">Пользователи</a></li> <li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/users}">Пользователи</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/teachers}">Преподаватели</a></li> <li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/teachers}">Преподаватели</a></li>

View File

@@ -0,0 +1,268 @@
package ru.ysu.lms.web.admin
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
import org.springframework.mock.web.MockServletContext
import org.thymeleaf.context.WebContext
import org.thymeleaf.spring6.SpringTemplateEngine
import org.thymeleaf.templatemode.TemplateMode
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.LabSummaryDto
import ru.ysu.lms.web.api.PromotionItemDto
import ru.ysu.lms.web.api.PromotionRunDetailDto
import ru.ysu.lms.web.api.PromotionRunSummaryDto
import java.util.Locale
class AdminStage4TemplateTest {
private val engine =
SpringTemplateEngine().apply {
setTemplateResolver(
ClassLoaderTemplateResolver().apply {
prefix = "templates/"
suffix = ".html"
characterEncoding = "UTF-8"
templateMode = TemplateMode.HTML
},
)
}
@Test
fun `renders promotion list with counts and detail link`() {
val runs =
PromotionViews.list(
listOf(
PromotionRunSummaryDto(
id = "r1",
year = 2026,
mode = "DRY_RUN",
status = "PLANNED",
startedAt = "2026-08-31T21:00:00Z",
result =
mapOf(
"counts" to mapOf("groupsPromote" to 2, "studentsAcademic" to 1),
"warnings" to listOf("Группа без курса"),
),
),
PromotionRunSummaryDto(
id = "r2",
year = 2025,
mode = "DRY_RUN",
status = "CANCELLED",
startedAt = "2025-08-31T21:00:00Z",
),
),
)
val html = render("admin/promotion", mapOf("runs" to runs))
assertThat(html).contains("Повышение")
assertThat(html).contains("2026")
assertThat(html).contains("Пробный")
assertThat(html).contains("Запланирован")
assertThat(html).contains("Отменён")
assertThat(html).contains("перевод групп: 2")
assertThat(html).contains("предупреждений: 1")
assertThat(html).contains("/admin/promotion/r1")
assertThat(html).contains("/admin/promotion/r2")
}
@Test
fun `renders promotion detail with apply action and before after fields`() {
val run =
PromotionViews.detail(
PromotionRunDetailDto(
id = "r1",
year = 2026,
mode = "DRY_RUN",
status = "PLANNED",
startedAt = "2026-08-31T21:00:00Z",
result = mapOf("counts" to mapOf("groupsPromote" to 1)),
items =
listOf(
PromotionItemDto(
id = "i1",
entityType = "GROUP",
entityId = "g1",
action = "PROMOTE",
status = "PLANNED",
before = mapOf("name" to "ПИ-21БО", "course" to 1),
after = mapOf("name" to "ПИ-22БО", "course" to 2),
),
PromotionItemDto(
id = "i2",
entityType = "STUDENT",
entityId = "s1",
action = "GRADUATE",
status = "ERROR",
error = "Студент не найден",
before = mapOf("fullName" to "Иванов Иван", "status" to "ACTIVE"),
after = mapOf("status" to "GRADUATED"),
),
),
),
)
val html = render("admin/promotion-detail", mapOf("run" to run))
assertThat(html).contains("Применить план")
assertThat(html).contains("Отменить план")
assertThat(html).contains("/admin/promotion/r1/cancel")
assertThat(html).doesNotContain("Перестроить план")
assertThat(html).doesNotContain("Откатить")
assertThat(html).contains("ПИ-21БО")
assertThat(html).contains("ПИ-22БО")
assertThat(html).contains("Перевод на следующий курс")
assertThat(html).contains("Выпуск")
assertThat(html).contains("Студент не найден")
assertThat(html).contains("активен")
}
@Test
fun `renders rollback action for completed promotion`() {
val run =
PromotionViews.detail(
PromotionRunDetailDto(
id = "r1",
year = 2026,
mode = "APPLY",
status = "COMPLETED",
startedAt = "2026-08-31T21:00:00Z",
finishedAt = "2026-08-31T21:01:00Z",
),
)
val html = render("admin/promotion-detail", mapOf("run" to run))
assertThat(html).contains("Откатить")
assertThat(html).doesNotContain("Применить план")
assertThat(html).doesNotContain("Отменить план")
assertThat(html).doesNotContain("Перестроить план")
}
@Test
fun `renders rebuild action for cancelled promotion`() {
val run =
PromotionViews.detail(
PromotionRunDetailDto(
id = "r2",
year = 2026,
mode = "DRY_RUN",
status = "CANCELLED",
startedAt = "2026-08-31T21:00:00Z",
finishedAt = "2026-08-31T21:01:00Z",
),
)
val html = render("admin/promotion-detail", mapOf("run" to run))
assertThat(html).contains("Отменён")
assertThat(html).contains("Перестроить план")
assertThat(html).contains("/admin/promotion/dry-run")
assertThat(html).contains("name=\"year\"")
assertThat(html).doesNotContain("Применить план")
assertThat(html).doesNotContain("Отменить план")
assertThat(html).doesNotContain("Откатить")
}
@Test
fun `renders group lifecycle buttons by student status`() {
val group =
GroupDetailDto(
id = "g1",
name = "ПИ-21БО",
status = "ACTIVE",
students = emptyList(),
)
val rows =
listOf(
studentRow("s1", "ACTIVE"),
studentRow("s2", "ACADEMIC_LEAVE"),
studentRow("s3", "ARCHIVED"),
studentRow("s4", "EXPELLED"),
)
val model =
linkedMapOf<String, Any?>(
"group" to group,
"labs" to listOf(LabSummaryDto(id = "l1", number = 1, title = "Введение")),
"candidates" to emptyList<Any>(),
"studentGrades" to rows,
"labPublication" to emptyMap<String, GroupLabPublication>(),
"labSubmissionCounts" to emptyMap<String, Int>(),
"attestations" to emptyList<GroupAttestation>(),
"publishedLabsCount" to 0,
"profileCourseId" to null,
)
val html = render("admin/group", model)
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).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("Убрать из группы")
}
@Test
fun `renders delete confirmation input on users page`() {
val users =
listOf(
AdminUserDto(
id = "u1",
login = "ivanov",
fullName = "Иванов Иван",
status = "ACTIVE",
groupId = "g1",
groupName = "ПИ-21БО",
),
)
val html = render("admin/users", mapOf("users" to users, "query" to ""))
assertThat(html).contains("/admin/users/u1/delete")
assertThat(html).contains("name=\"confirm\"")
assertThat(html).contains("Логин или ФИО")
}
private fun studentRow(
id: String,
status: String,
): GroupStudentRow =
GroupStudentRow(
studentId = id,
fullName = "Студент $id",
studentNo = "000$id",
basis = "",
status = status,
mustChangePassword = false,
cells = emptyList(),
variantsSummary = "",
commentsJson = "[]",
)
private fun render(
template: String,
variables: Map<String, Any?>,
): String {
val servletContext = MockServletContext()
val application = JakartaServletWebApplication.buildApplication(servletContext)
val exchange =
application.buildExchange(
MockHttpServletRequest(servletContext),
MockHttpServletResponse(),
)
val context = WebContext(exchange, Locale.forLanguageTag("ru"))
context.setVariable("csrfToken", "csrf")
variables.forEach { (name, value) -> context.setVariable(name, value) }
return engine.process(template, context)
}
}

View File

@@ -0,0 +1,80 @@
package ru.ysu.lms.web.api
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import tools.jackson.databind.ObjectMapper
class PromotionDtoTest {
private val mapper = ObjectMapper()
@Test
fun `deserializes promotion detail with item states and result`() {
val json =
"""
{
"id": "r1",
"year": 2026,
"mode": "DRY_RUN",
"status": "PLANNED",
"actorAccountId": null,
"startedAt": "2026-08-31T21:00:00Z",
"finishedAt": null,
"result": {
"generatedAt": "2026-08-31T21:00:00Z",
"counts": {"groupsPromote": 2, "studentsAcademic": 1},
"warnings": ["Группа без курса"]
},
"items": [
{
"id": "i1",
"entityType": "GROUP",
"entityId": "g1",
"action": "PROMOTE",
"status": "PLANNED",
"error": "",
"createdAt": "2026-08-31T21:00:00Z",
"before": {"name": "ПИ-21БО", "course": 1},
"after": {"name": "ПИ-22БО", "course": 2}
}
],
"unknownField": "ignored"
}
""".trimIndent()
val run = mapper.readValue(json, PromotionRunDetailDto::class.java)
assertThat(run.id).isEqualTo("r1")
assertThat(run.result?.get("warnings")).isEqualTo(listOf("Группа без курса"))
assertThat(run.items).hasSize(1)
assertThat(run.items[0].entityType).isEqualTo("GROUP")
assertThat(run.items[0].before["name"]).isEqualTo("ПИ-21БО")
assertThat(run.items[0].after["course"]).isEqualTo(2)
}
@Test
fun `deserializes student lifecycle result`() {
val json =
"""
{
"studentId": "s1",
"fullName": "Иванов Иван",
"status": "ACADEMIC_LEAVE",
"action": "ACADEMIC",
"fromGroupId": "g1",
"fromGroupName": "ПИ-21БО",
"toGroupId": "g2",
"toGroupName": "ПИ-21БО (2025)",
"enrollmentYear": 2025,
"unknownField": "ignored"
}
""".trimIndent()
val result = mapper.readValue(json, StudentLifecycleResultDto::class.java)
assertThat(result.fullName).isEqualTo("Иванов Иван")
assertThat(result.status).isEqualTo("ACADEMIC_LEAVE")
assertThat(result.action).isEqualTo("ACADEMIC")
assertThat(result.toGroupName).isEqualTo("ПИ-21БО (2025)")
assertThat(result.enrollmentYear).isEqualTo(2025.toShort())
}
}