feat(web): повышение курса и жизненный цикл студентов
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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 // аккаунта уже нет — дочищаем данные после прошлого сбоя
|
||||||
|
|||||||
271
web/src/main/kotlin/ru/ysu/lms/web/admin/PromotionViews.kt
Normal file
271
web/src/main/kotlin/ru/ysu/lms/web/admin/PromotionViews.kt
Normal 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() }
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
84
web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientPromotion.kt
Normal file
84
web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientPromotion.kt
Normal 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
125
web/src/main/resources/templates/admin/promotion-detail.html
Normal file
125
web/src/main/resources/templates/admin/promotion-detail.html
Normal 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>
|
||||||
71
web/src/main/resources/templates/admin/promotion.html
Normal file
71
web/src/main/resources/templates/admin/promotion.html
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
80
web/src/test/kotlin/ru/ysu/lms/web/api/PromotionDtoTest.kt
Normal file
80
web/src/test/kotlin/ru/ysu/lms/web/api/PromotionDtoTest.kt
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user