diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupArchiveController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupArchiveController.kt new file mode 100644 index 0000000..f4cb541 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupArchiveController.kt @@ -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" + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminPromotionController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminPromotionController.kt new file mode 100644 index 0000000..73c63a5 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminPromotionController.kt @@ -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" + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminStudentLifecycleController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminStudentLifecycleController.kt new file mode 100644 index 0000000..1050cc0 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminStudentLifecycleController.kt @@ -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() +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminUsersController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminUsersController.kt index 8178f48..3ae907c 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminUsersController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminUsersController.kt @@ -42,6 +42,7 @@ class AdminUsersController( @PostMapping("/{id}/delete") fun delete( @PathVariable id: String, + @RequestParam(required = false, name = "confirm") confirm: String?, @RequestParam(required = false, name = "q") query: String?, session: HttpSession, redirect: RedirectAttributes, @@ -51,6 +52,10 @@ class AdminUsersController( redirect.addFlashAttribute("error", "Нельзя удалить собственный аккаунт") return redirectTo(query) } + if (confirm.isNullOrBlank()) { + redirect.addFlashAttribute("error", "Чтобы удалить пользователя, введите его логин или ФИО") + return redirectTo(query) + } return try { val t = token(session) val student = api.searchStudents(t, null).firstOrNull { it.id == id } @@ -58,7 +63,7 @@ class AdminUsersController( // данные-сироты (дочищаются повторным запуском), а не «живой» аккаунт без данных. val deleted = try { - api.deleteStudent(t, id) + api.deleteStudent(t, id, confirm) } catch (alreadyGone: ApiException) { if (alreadyGone.status != 404) throw alreadyGone null // аккаунта уже нет — дочищаем данные после прошлого сбоя diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/PromotionViews.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/PromotionViews.kt new file mode 100644 index 0000000..bf25913 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/PromotionViews.kt @@ -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, + val warnings: List, +) + +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, + val after: List, +) + +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, + val warnings: List, + val items: List, + 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): List = 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?, + items: List, + ): List { + 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?): List = + (result?.get("warnings") as? List<*>)?.mapNotNull { it?.toString()?.takeIf { text -> text.isNotBlank() } }.orEmpty() + + private fun stateFields( + entityType: String, + state: Map, + ): List = + 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.text(key: String): String? = (this[key] as? String)?.takeIf { it.isNotBlank() } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt index 850135a..66be390 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt @@ -150,16 +150,31 @@ fun ApiClient.searchStudents( fun ApiClient.deleteStudent( token: String, studentId: String, + confirm: String, ): DeleteUserDto = call { client .delete() - .uri("/api/v1/students/{id}", studentId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .uri { builder -> + builder.path("/api/v1/students/{id}").queryParam("confirm", confirm).build(studentId) + }.header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve() .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( token: String, groupId: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientPromotion.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientPromotion.kt new file mode 100644 index 0000000..27f63c1 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientPromotion.kt @@ -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 { + val type = object : ParameterizedTypeReference>() {} + 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) + } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientStudentLifecycle.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientStudentLifecycle.kt new file mode 100644 index 0000000..bc187f7 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientStudentLifecycle.kt @@ -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) + } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt index bc07df1..120a681 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt @@ -795,3 +795,67 @@ data class ProfileAuthorDto( val id: 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? = 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 = emptyMap(), + val after: Map = 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? = null, + val items: List = 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, +) diff --git a/web/src/main/resources/templates/admin/group.html b/web/src/main/resources/templates/admin/group.html index a6315a6..5a0d2cb 100644 --- a/web/src/main/resources/templates/admin/group.html +++ b/web/src/main/resources/templates/admin/group.html @@ -26,6 +26,12 @@ +
+ + +
@@ -195,11 +201,52 @@ -
+ + + + +
+
+ + +
+
+ +
+ + +
+
+ + +
+ +
+ + +
diff --git a/web/src/main/resources/templates/admin/promotion-detail.html b/web/src/main/resources/templates/admin/promotion-detail.html new file mode 100644 index 0000000..b394199 --- /dev/null +++ b/web/src/main/resources/templates/admin/promotion-detail.html @@ -0,0 +1,125 @@ + + +Повышение + +
+ ← Все запуски + +
+
+

Журнал повышения

+

Повышение

+

+ — + Пробный + Начат: — + · Завершён: — +

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ +
+
+ — +
+
+ План построен: — + Применён: — + Откатан: — +
+

+ План отменён или завершился ошибкой. Постройте новый пробный план на этот же год. +

+
+ +
+

Предупреждения

+
    +
  • —
  • +
+

Эти группы или студенты пропущены при построении плана.

+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
СущностьДействиеСтатусБылоСталоОшибка
+
—
+
+ Группа + — +
+
— + — + +
+ — + — +
+ — +
+
+ — + — +
+ — +
+ — + — +
В плане нет элементов.
+
+
+
+ + diff --git a/web/src/main/resources/templates/admin/promotion.html b/web/src/main/resources/templates/admin/promotion.html new file mode 100644 index 0000000..5060dee --- /dev/null +++ b/web/src/main/resources/templates/admin/promotion.html @@ -0,0 +1,71 @@ + + +Повышение — LMS + +
+
+
+

Повышение

+

Планы перевода групп и студентов на следующий учебный год.

+
+
+ +
+ +

Построить пробный план

+
+
+ + +
+ +
+

+ Если год не указан, используется текущий. Пробный план ничего не меняет — его можно проверить и применить. +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ГодРежимСтатусНачалоЗавершениеСостав плана
2026Пробный + — + —— +
+ — + — +
+
+ Открыть +
Запусков повышения ещё не было.
+
+
+ + diff --git a/web/src/main/resources/templates/admin/users.html b/web/src/main/resources/templates/admin/users.html index 5cf9409..f9d097e 100644 --- a/web/src/main/resources/templates/admin/users.html +++ b/web/src/main/resources/templates/admin/users.html @@ -33,11 +33,15 @@ th:text="${u.status == 'ACTIVE' ? 'активен' : (u.status == 'EXPELLED' ? 'отчислен' : (u.status == 'ACADEMIC_LEAVE' ? 'академ' : (u.status == 'GRADUATED' ? 'выпущен' : 'архив')))}">— -
+ +
+

Введите логин или ФИО для подтверждения. Удаляются аккаунт и все данные.

Никого не нашли. diff --git a/web/src/main/resources/templates/fragments/base.html b/web/src/main/resources/templates/fragments/base.html index 86c0877..bb23523 100644 --- a/web/src/main/resources/templates/fragments/base.html +++ b/web/src/main/resources/templates/fragments/base.html @@ -49,6 +49,7 @@