From 97e14f040ce96c08129d2c4eea53ee8458ad23db Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Tue, 27 Oct 2026 20:39:28 +0300 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8C=20=D0=BF=D1=80=D0=B5=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F,=20=D0=BC=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D1=80=D0=B8=D0=B0=D0=BB=D1=8B=20=D0=BA=D1=83=D1=80?= =?UTF-8?q?=D1=81=D0=B0=20=D0=B8=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ysu/lms/web/admin/AdminAuditController.kt | 50 +++ .../lms/web/admin/AdminDefenseController.kt | 22 +- .../web/admin/AdminDistributeController.kt | 23 +- .../ysu/lms/web/admin/AdminGroupController.kt | 240 +++++++++---- .../ru/ysu/lms/web/admin/AdminSupport.kt | 16 +- .../kotlin/ru/ysu/lms/web/admin/AuditViews.kt | 162 +++++++++ .../ru/ysu/lms/web/api/ApiClientAudit.kt | 24 ++ .../ru/ysu/lms/web/api/ApiClientCourse.kt | 16 + .../ysu/lms/web/api/ApiClientCourseContent.kt | 200 +++++++++++ .../ru/ysu/lms/web/api/ApiClientProgress.kt | 12 +- .../main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt | 21 ++ .../ysu/lms/web/session/AdminInterceptor.kt | 51 ++- .../ysu/lms/web/teacher/TeacherController.kt | 63 +++- .../teacher/TeacherCourseContentController.kt | 333 ++++++++++++++++++ .../main/resources/templates/admin/audit.html | 69 ++++ .../main/resources/templates/admin/group.html | 50 +-- .../resources/templates/fragments/base.html | 1 + .../templates/teacher/content-article.html | 86 +++++ .../resources/templates/teacher/content.html | 186 ++++++++++ .../resources/templates/teacher/course.html | 41 ++- 20 files changed, 1542 insertions(+), 124 deletions(-) create mode 100644 web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAuditController.kt create mode 100644 web/src/main/kotlin/ru/ysu/lms/web/admin/AuditViews.kt create mode 100644 web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAudit.kt create mode 100644 web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourseContent.kt create mode 100644 web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherCourseContentController.kt create mode 100644 web/src/main/resources/templates/admin/audit.html create mode 100644 web/src/main/resources/templates/teacher/content-article.html create mode 100644 web/src/main/resources/templates/teacher/content.html diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAuditController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAuditController.kt new file mode 100644 index 0000000..c6ef4d3 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAuditController.kt @@ -0,0 +1,50 @@ +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.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +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.AuditEventDto +import ru.ysu.lms.web.api.listAudit +import tools.jackson.databind.ObjectMapper + +@Controller +@RequestMapping("/admin/audit") +class AdminAuditController( + api: ApiClient, + markdown: MarkdownRenderer, + mapper: ObjectMapper, +) : AdminSupport(api, markdown, mapper) { + @GetMapping + fun list( + @RequestParam(required = false) action: String?, + @RequestParam(required = false) limit: String?, + session: HttpSession, + model: Model, + ): String { + val filter = action?.trim()?.takeIf { it.isNotEmpty() } + val pageSize = (limit?.trim()?.toIntOrNull() ?: DEFAULT_LIMIT).coerceIn(1, MAX_LIMIT) + val events: List = + try { + api.listAudit(token(session), pageSize, filter) + } catch (ex: ApiException) { + model.addAttribute("error", ex.message) + emptyList() + } + model.addAttribute("events", AuditViews.rows(events)) + model.addAttribute("action", filter.orEmpty()) + model.addAttribute("limit", pageSize) + model.addAttribute("actionOptions", AuditViews.actionOptions(filter)) + return "admin/audit" + } + + private companion object { + const val DEFAULT_LIMIT = 100 + const val MAX_LIMIT = 500 + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDefenseController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDefenseController.kt index 1b10eb1..ddd0a89 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDefenseController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDefenseController.kt @@ -62,16 +62,17 @@ class AdminDefenseController( fun assignDefense( @PathVariable labId: String, @ModelAttribute form: DefenseForm, + @RequestParam(required = false) courseId: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { api.assignDefense(token(session), form.studentId, labId, form.assignmentMd) redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено") - redirectToGroup(form.groupId, labId) + redirectToGroup(form.groupId, labId, courseId) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - redirectToGroup(form.groupId, labId) + redirectToGroup(form.groupId, labId, courseId) } /** Назначение/правка индивидуального задания со страницы группы. */ @@ -79,16 +80,17 @@ class AdminDefenseController( fun saveDefense( @ModelAttribute form: DefenseForm, @RequestParam labId: String, + @RequestParam(required = false) courseId: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { api.assignDefense(token(session), form.studentId, labId, form.assignmentMd) redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено") - redirectToGroup(form.groupId, labId) + redirectToGroup(form.groupId, labId, courseId) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - redirectToGroup(form.groupId, labId) + redirectToGroup(form.groupId, labId, courseId) } @PostMapping("/labs/{labId}/defense/import") @@ -111,37 +113,41 @@ class AdminDefenseController( redirect: RedirectAttributes, ): String = importDefenses(token(session), labId, file, groupId, redirect) + @Suppress("LongParameterList") @PostMapping("/defenses/{id}/issue-code") fun issueDefenseCode( @PathVariable id: String, @RequestParam labId: String, @RequestParam(required = false) groupId: String?, + @RequestParam(required = false) courseId: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { val code = api.issueDefenseCode(token(session), id) redirect.addFlashAttribute("notice", "Код для входа на защиту: $code") - redirectToGroup(groupId, labId) + redirectToGroup(groupId, labId, courseId) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - redirectToGroup(groupId, labId) + redirectToGroup(groupId, labId, courseId) } + @Suppress("LongParameterList") @PostMapping("/defenses/{id}/reset") fun resetDefenseSession( @PathVariable id: String, @RequestParam labId: String, @RequestParam(required = false) groupId: String?, + @RequestParam(required = false) courseId: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { api.resetDefense(token(session), id) redirect.addFlashAttribute("notice", "Сессия защиты сброшена в ожидание кода") - redirectToGroup(groupId, labId) + redirectToGroup(groupId, labId, courseId) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - redirectToGroup(groupId, labId) + redirectToGroup(groupId, labId, courseId) } } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDistributeController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDistributeController.kt index 94cb0a7..0b4bf71 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDistributeController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDistributeController.kt @@ -31,39 +31,43 @@ class AdminDistributeController( fun setStudentLevel( @PathVariable gid: String, @ModelAttribute form: StudentLevelForm, + @RequestParam(required = false) courseId: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { api.setLevel(token(session), LevelAssignPayload(form.studentId, form.labId, form.labLevelId)) redirect.addFlashAttribute("notice", "Уровень обновлён — вариант подобран автоматически") - "redirect:/admin/groups/$gid" + groupRedirect(gid, courseId) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/groups/$gid" + groupRedirect(gid, courseId) } @PostMapping("/groups/{gid}/pin-variant") fun pinStudentVariant( @PathVariable gid: String, @ModelAttribute form: PinVariantForm, + @RequestParam(required = false) courseId: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { val result = api.assignVariant(token(session), AssignVariantPayload(form.studentId, form.labId, form.variantId)) redirect.addFlashAttribute("notice", "Закреплён вариант ${result.variantNumber}") - "redirect:/admin/groups/$gid" + groupRedirect(gid, courseId) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/groups/$gid" + groupRedirect(gid, courseId) } + @Suppress("LongParameterList") @PostMapping("/groups/{gid}/distribute") fun distributeForGroup( @PathVariable gid: String, @RequestParam labId: String, @RequestParam(defaultValue = "false") force: Boolean, + @RequestParam(required = false) courseId: String?, session: HttpSession, redirect: RedirectAttributes, ): String = @@ -74,10 +78,10 @@ class AdminDistributeController( DistributeRequestPayload(labId = labId, force = force, groupIds = listOf(gid)), ) redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}") - "redirect:/admin/groups/$gid" + groupRedirect(gid, courseId) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/groups/$gid" + groupRedirect(gid, courseId) } /** Раздать варианты по всем опубликованным ЛР для одной группы. */ @@ -85,6 +89,7 @@ class AdminDistributeController( fun distributeAllForGroup( @PathVariable gid: String, @RequestParam(defaultValue = "false") force: Boolean, + @RequestParam(required = false) courseId: String?, session: HttpSession, redirect: RedirectAttributes, ): String = @@ -93,7 +98,7 @@ class AdminDistributeController( val labs = api.listLabs(t).filter { it.status == "published" } if (labs.isEmpty()) { redirect.addFlashAttribute("error", "Нет опубликованных лабораторных") - return "redirect:/admin/groups/$gid" + return groupRedirect(gid, courseId) } val result = api.distribute( @@ -101,10 +106,10 @@ class AdminDistributeController( DistributeRequestPayload(labIds = labs.map { it.id }, force = force, groupIds = listOf(gid)), ) redirect.addFlashAttribute("notice", "Распределено по ${result.labs} ЛР: ${result.assigned} из ${result.total}") - "redirect:/admin/groups/$gid" + groupRedirect(gid, courseId) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/groups/$gid" + groupRedirect(gid, courseId) } /** Массовая установка уровня выделенным студентам. */ diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt index 16c5f63..a35f351 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt @@ -21,14 +21,19 @@ import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.CreateGroupPayload import ru.ysu.lms.web.api.DefenseDto +import ru.ysu.lms.web.api.GradeDto import ru.ysu.lms.web.api.GradeSetPayload import ru.ysu.lms.web.api.GroupDetailDto +import ru.ysu.lms.web.api.LabDataDto import ru.ysu.lms.web.api.LabSummaryDto import ru.ysu.lms.web.api.PublicationSetPayload +import ru.ysu.lms.web.api.PublicationStateDto import ru.ysu.lms.web.api.StudentCandidateDto import ru.ysu.lms.web.api.StudentRowDto import ru.ysu.lms.web.api.SubmissionDto import ru.ysu.lms.web.api.VariantAssignmentResultDto +import ru.ysu.lms.web.api.courseDetail +import ru.ysu.lms.web.api.courseGroupStudents import ru.ysu.lms.web.api.createGroup import ru.ysu.lms.web.api.deleteCommentTemplate import ru.ysu.lms.web.api.downloadSubmissionAsAdmin @@ -48,6 +53,8 @@ import ru.ysu.lms.web.api.publicationState import ru.ysu.lms.web.api.setGrade import ru.ysu.lms.web.api.setPublication import ru.ysu.lms.web.api.suggestComments +import ru.ysu.lms.web.session.SessionKeys +import ru.ysu.lms.web.session.SessionUser import tools.jackson.databind.ObjectMapper import java.math.BigDecimal import java.math.RoundingMode @@ -56,6 +63,7 @@ import java.nio.charset.StandardCharsets @Controller @RequestMapping("/admin") +@Suppress("TooManyFunctions") class AdminGroupController( api: ApiClient, markdown: MarkdownRenderer, @@ -153,26 +161,95 @@ class AdminGroupController( model: Model, ): String { val t = token(session) - val allLabs = api.listLabs(t) - val group = api.groupDetail(t, id) - val labs = allLabs.filter { it.status == "published" } - val attestations = loadAttestations(t, id) - val grades = api.listGrades(t) + val teacher = isTeacher(session) + val effectiveCourseId = courseId?.trim()?.takeIf { it.isNotBlank() } + val view = + if (teacher) { + teacherGroupView(t, id, effectiveCourseId) + } else { + adminGroupView(t, id) + } + fillGroupModel(model, view) + model.addAttribute("teacherView", teacher) + model.addAttribute("profileCourseId", effectiveCourseId) + return "admin/group" + } + + /** Администратор видит все группы и аттестации; преподаватель — только свой курс. */ + private fun adminGroupView( + token: String, + id: String, + ): GroupViewModel { + val labs = api.listLabs(token).filter { it.status == "published" } + val group = api.groupDetail(token, id) + val attestations = loadAttestations(token, id) + val grades = api.listGrades(token) + val dataByLab = api.groupLabData(token, id, labs.map { it.id }).labs.associateBy { it.labId } + val candidates = runCatching { api.groupCandidates(token, id) }.getOrDefault(emptyList()) + return groupViewModel(group, labs, grades, dataByLab, attestations, candidates) + } + + /** + * Ведомость преподавателя: состав группы берётся из academic в рамках курса, + * чужие лабораторные и оценки отфильтровываются, админские разделы не запрашиваются. + */ + private fun teacherGroupView( + token: String, + id: String, + courseId: String?, + ): GroupViewModel { + if (courseId == null) throw ApiException(400, "Не указан курс") + api.courseDetail(token, courseId) + val roster = api.courseGroupStudents(token, courseId, id) + val group = + GroupDetailDto( + id = id, + name = roster.groupName.ifBlank { id }, + students = + roster.students.map { student -> + StudentRowDto( + id = student.id, + studentNo = student.studentNo, + fullName = student.fullName, + status = student.status, + ) + }, + ) + val labs = api.listLabs(token).filter { it.courseId == courseId && it.status == "published" } + val labIds = labs.map { it.id }.toSet() + val memberIds = group.students.map { it.id }.toSet() + val grades = api.listGrades(token, courseId).filter { it.studentId in memberIds && it.labId in labIds } + val dataByLab = + if (labs.isEmpty()) { + emptyMap() + } else { + api.groupLabData(token, id, labs.map { it.id }).labs.associateBy { it.labId } + } + return groupViewModel(group, labs, grades, dataByLab, emptyList(), emptyList()) + } + + @Suppress("LongParameterList") + private fun groupViewModel( + group: GroupDetailDto, + labs: List, + grades: List, + dataByLab: Map, + attestations: List, + candidates: List, + ): GroupViewModel { val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score } val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment } val commentsBy = grades.associate { "${it.studentId}|${it.labId}" to it.comments.map { ct -> ct.text to ct.tone } } val memberIds = group.students.map { it.id } val memberIdSet = memberIds.toSet() - // Данные всех лаб одним запросом вместо 4 вызовов на каждую лабу - val dataByLab = api.groupLabData(t, id, labs.map { it.id }).labs.associateBy { it.labId } val publishedByLab = labs.associate { lab -> - val state = dataByLab.getValue(lab.id).publication + val state = dataByLab[lab.id]?.publication ?: PublicationStateDto() lab.id to if (state.all) memberIdSet else state.studentIds.filter { it in memberIdSet }.toSet() } val labPublication = labs.associate { lab -> - val effective = publishedByLab.getValue(lab.id) + val effective = publishedByLab[lab.id].orEmpty() lab.id to GroupLabPublication( allPublished = memberIds.isNotEmpty() && memberIds.all { it in effective }, @@ -180,9 +257,12 @@ class AdminGroupController( total = memberIds.size, ) } - val assignmentsBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).assignments.associateBy { it.studentId } } - val submissionsBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).submissions.associateBy { it.studentId } } - val defensesBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).defenses.associateBy { it.studentId } } + val assignmentsBy = + labs.associateWith { lab -> dataByLab[lab.id]?.assignments.orEmpty().associateBy { it.studentId } } + val submissionsBy = + labs.associateWith { lab -> dataByLab[lab.id]?.submissions.orEmpty().associateBy { it.studentId } } + val defensesBy = + labs.associateWith { lab -> dataByLab[lab.id]?.defenses.orEmpty().associateBy { it.studentId } } val rows = GroupAttestationViews.attach( @@ -199,17 +279,19 @@ class AdminGroupController( ), attestations, ) - - fillGroupModel( - model, - groupViewModel( - t, - id, - GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations), - ), + val submissionCounts = + labs.associate { lab -> + lab.id to (submissionsBy[lab]?.keys?.count { it in memberIdSet } ?: 0) + } + return GroupViewModel( + group, + labs, + candidates, + rows, + labPublication, + submissionCounts, + attestations, ) - model.addAttribute("profileCourseId", courseId?.takeIf { it.isNotBlank() }) - return "admin/group" } @Suppress("LongParameterList") @@ -296,40 +378,6 @@ class AdminGroupController( .toPlainString() } - private data class GroupRenderData( - val group: GroupDetailDto, - val labs: List, - val rows: List, - val labPublication: Map, - val submissionsBy: Map>, - val attestations: List, - ) - - private fun groupViewModel( - token: String, - id: String, - data: GroupRenderData, - ): GroupViewModel { - val memberIds = - data.group.students - .map { it.id } - .toSet() - val submissionCounts = - data.labs.associate { lab -> - lab.id to (data.submissionsBy[lab]?.keys?.count { it in memberIds } ?: 0) - } - val candidates = runCatching { api.groupCandidates(token, id) }.getOrDefault(emptyList()) - return GroupViewModel( - data.group, - data.labs, - candidates, - data.rows, - data.labPublication, - submissionCounts, - data.attestations, - ) - } - private data class GroupViewModel( val group: GroupDetailDto, val labs: List, @@ -359,11 +407,13 @@ class AdminGroupController( fun assignModal( @PathVariable id: String, @RequestParam labId: String, + @RequestParam(required = false) courseId: String?, session: HttpSession, model: Model, ): String { val t = token(session) - val group = api.groupDetail(t, id) + val teacher = isTeacher(session) + val students = groupStudentsFor(t, id, courseId, teacher) val lab = api.labDetail(t, labId) val levels = api.getLevels(t, labId).associateBy { it.studentId } val assignments = api.getLabAssignments(t, labId).associateBy { it.studentId } @@ -371,7 +421,7 @@ class AdminGroupController( val variantUsage = java.util.HashMap() assignments.values.forEach { variantUsage[it.variantId] = (variantUsage[it.variantId] ?: 0) + 1 } val rows = - group.students.map { s -> + students.map { s -> val assignment = assignments[s.id] AssignModalRow( studentId = s.id, @@ -389,10 +439,40 @@ class AdminGroupController( model.addAttribute("groupId", id) model.addAttribute("rows", rows) model.addAttribute("variantUsage", variantUsage) - model.addAttribute("refreshUrl", "/admin/groups/$id/assign-modal?labId=$labId") + val courseQuery = + courseId + ?.trim() + ?.takeIf { it.isNotBlank() } + ?.let { "&courseId=$it" } + .orEmpty() + model.addAttribute("refreshUrl", "/admin/groups/$id/assign-modal?labId=$labId$courseQuery") return "admin/assign-fragment" } + private fun isTeacher(session: HttpSession): Boolean = (session.getAttribute(SessionKeys.USER) as? SessionUser)?.isTeacher == true + + /** Студенты группы: для преподавателя — состав курса из academic, для админа — карточка группы. */ + private fun groupStudentsFor( + token: String, + groupId: String, + courseId: String?, + teacher: Boolean, + ): List { + if (!teacher) return api.groupDetail(token, groupId).students + val effectiveCourseId = + courseId?.trim()?.takeIf { it.isNotBlank() } + ?: throw ApiException(400, "Не указан курс") + api.courseDetail(token, effectiveCourseId) + return api.courseGroupStudents(token, effectiveCourseId, groupId).students.map { student -> + StudentRowDto( + id = student.id, + studentNo = student.studentNo, + fullName = student.fullName, + status = student.status, + ) + } + } + private data class AssignModalRow( val studentId: String, val fullName: String, @@ -409,6 +489,7 @@ class AdminGroupController( fun setGrade( @PathVariable id: String, @ModelAttribute form: SetGradeForm, + @RequestParam(required = false) courseId: String?, session: HttpSession, redirect: RedirectAttributes, ): String = @@ -428,10 +509,10 @@ class AdminGroupController( ), ) redirect.addFlashAttribute("notice", "Оценка сохранена") - "redirect:/admin/groups/$id" + groupRedirect(id, courseId) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/groups/$id" + groupRedirect(id, courseId) } /** Уровни и варианты лабораторной для модалки назначения варианта. */ @@ -490,37 +571,55 @@ class AdminGroupController( fun downloadGroupSubmissions( @PathVariable id: String, @RequestParam(required = false) labId: String?, + @RequestParam(required = false) courseId: String?, session: HttpSession, ): ResponseEntity { val t = token(session) - val group = api.groupDetail(t, id) - val studentById = group.students.associateBy { it.id } - val labs = api.listLabs(t).filter { lab -> labId == null || lab.id == labId } - val bytes = buildGroupArchive(t, group.name, studentById, labs) - return zipAttachment("solutions-${group.name}.zip", bytes) + val teacher = isTeacher(session) + val students = groupStudentsFor(t, id, courseId, teacher) + val studentById = students.associateBy { it.id } + val labs = api.listLabs(t).filter { lab -> (labId == null || lab.id == labId) && (!teacher || lab.courseId == courseId) } + val groupName = if (teacher) groupNameFor(t, id, courseId) else api.groupDetail(t, id).name + val bytes = buildGroupArchive(t, groupName, studentById, labs) + return zipAttachment("solutions-$groupName.zip", bytes) + } + + /** Название группы для преподавателя: из состава курса, с откатом на id. */ + private fun groupNameFor( + token: String, + groupId: String, + courseId: String?, + ): String { + val effectiveCourseId = courseId?.trim()?.takeIf { it.isNotBlank() } ?: return groupId + return runCatching { api.courseGroupStudents(token, effectiveCourseId, groupId).groupName } + .getOrDefault("") + .ifBlank { groupId } } /** Оптовое действие из шапки ЛР: опубликовать всей группе или скрыть у всей группы. */ + @Suppress("LongParameterList") @PostMapping("/groups/{id}/publish") fun publishGrades( @PathVariable id: String, @RequestParam labId: String, @RequestParam published: Boolean, + @RequestParam(required = false) courseId: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { val t = token(session) - val all = api.groupDetail(t, id).students.map { it.id } + val all = groupStudentsFor(t, id, courseId, isTeacher(session)).map { it.id } api.setPublication(t, PublicationSetPayload(id, labId, if (published) all else emptyList())) redirect.addFlashAttribute("notice", if (published) "Оценки опубликованы всей группе" else "Оценки скрыты у всей группы") - "redirect:/admin/groups/$id" + groupRedirect(id, courseId) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/groups/$id" + groupRedirect(id, courseId) } /** Публикация/скрытие оценки и комментариев для одного студента (переключатель в ячейке). */ + @Suppress("LongParameterList") @PostMapping("/groups/{id}/publish-student") @ResponseBody fun publishStudent( @@ -528,11 +627,12 @@ class AdminGroupController( @RequestParam labId: String, @RequestParam studentId: String, @RequestParam published: Boolean, + @RequestParam(required = false) courseId: String?, session: HttpSession, ): Map = try { val t = token(session) - val memberIds = api.groupDetail(t, id).students.map { it.id } + val memberIds = groupStudentsFor(t, id, courseId, isTeacher(session)).map { it.id } val memberIdSet = memberIds.toSet() val state = api.publicationState(t, id, labId) val current = diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminSupport.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminSupport.kt index 4e7e2ba..f5d3a2a 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminSupport.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminSupport.kt @@ -150,13 +150,27 @@ abstract class AdminSupport( protected fun redirectToGroup( groupId: String?, labId: String?, + courseId: String? = null, ): String = when { - !groupId.isNullOrBlank() -> "redirect:/admin/groups/$groupId" + !groupId.isNullOrBlank() -> groupRedirect(groupId, courseId) !labId.isNullOrBlank() -> "redirect:/admin/labs/$labId" else -> "redirect:/admin/groups" } + /** Возврат на ведомость группы; преподаватель дополнительно сохраняет courseId. */ + protected fun groupRedirect( + groupId: String, + courseId: String?, + ): String = "redirect:/admin/groups/$groupId${courseQuery(courseId)}" + + private fun courseQuery(courseId: String?): String = + courseId + ?.trim() + ?.takeIf { it.isNotBlank() } + ?.let { "?courseId=" + URLEncoder.encode(it, StandardCharsets.UTF_8) } + .orEmpty() + protected fun buildGroupArchive( token: String, groupName: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AuditViews.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AuditViews.kt new file mode 100644 index 0000000..598b210 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AuditViews.kt @@ -0,0 +1,162 @@ +package ru.ysu.lms.web.admin + +import ru.ysu.lms.web.DateUtils +import ru.ysu.lms.web.api.AuditEventDto +import java.time.Instant + +data class AuditActionOption( + val value: String, + val label: String, +) + +data class AuditRow( + val occurredMsk: String?, + val actorLabel: String, + val action: String, + val actionLabel: String, + val entityType: String, + val entityId: String?, + val entityLabel: String, + val courseId: String?, + val studentId: String?, + val details: String, +) + +/** Представление журнала аудита для шаблонов админки. */ +object AuditViews { + private val ACTION_LABELS = + linkedMapOf( + "GRADE_SET" to "Выставлена оценка", + "GRADE_PUBLISH" to "Публикация оценок", + "DEFENSE_ASSIGN" to "Назначено задание защиты", + "DEFENSE_REMOVE" to "Снято задание защиты", + "DEFENSE_CODE_ISSUE" to "Выдан код защиты", + "DEFENSE_RESET" to "Сброс защиты", + "VARIANT_ASSIGN" to "Назначен вариант", + "VARIANT_DISTRIBUTE" to "Распределены варианты", + "VARIANT_LEVEL_SET" to "Установлен уровень", + "PROFILE_VIEW" to "Просмотр профиля студента", + "SUBMISSION_DOWNLOAD" to "Скачивание сдачи", + "SUBMISSION_DOWNLOAD_SELF" to "Скачивание своей сдачи", + "PROMOTION_DRY_RUN" to "Пробный план повышения", + "PROMOTION_APPLY" to "Повышение применено", + "PROMOTION_ROLLBACK" to "Повышение откатано", + "PROMOTION_CANCEL" to "План повышения отменён", + "TEACHER_CREATE" to "Создан преподаватель", + "TEACHER_PASSWORD_RESET" to "Сброшен пароль преподавателя", + "COURSE_CREATE" to "Создан курс", + "COURSE_UPDATE" to "Изменён курс", + "COURSE_TEACHER_ASSIGN" to "Назначен преподаватель курса", + "COURSE_TEACHER_REMOVE" to "Снят преподаватель курса", + "COURSE_GROUP_ATTACH" to "Группа привязана к курсу", + "COURSE_GROUP_DETACH" to "Группа отвязана от курса", + "COURSE_BOOTSTRAP" to "Bootstrap курса", + "CONTENT_IMPORT" to "Импорт материалов", + "CONTENT_CHAPTER_CREATE" to "Создана глава", + "CONTENT_CHAPTER_RENAME" to "Переименована глава", + "CONTENT_CHAPTER_DELETE" to "Удалена глава", + "CONTENT_CHAPTER_PUBLISH" to "Публикация главы", + "CONTENT_SECTION_CREATE" to "Создан раздел", + "CONTENT_SECTION_RENAME" to "Переименован раздел", + "CONTENT_SECTION_DELETE" to "Удалён раздел", + "CONTENT_SECTION_PUBLISH" to "Публикация раздела", + "CONTENT_ARTICLE_CREATE" to "Создана статья", + "CONTENT_ARTICLE_UPDATE" to "Изменена статья", + "CONTENT_ARTICLE_DELETE" to "Удалена статья", + "CONTENT_ARTICLE_MOVE" to "Статья перемещена", + "CONTENT_ARTICLE_PUBLISH" to "Публикация статьи", + "LAB_CREATE" to "Создана лабораторная", + "LAB_IMPORT" to "Импорт лабораторной", + "LAB_UPDATE" to "Изменена лабораторная", + "LAB_DELETE" to "Удалена лабораторная", + "LAB_COPY" to "Скопирована лабораторная", + "LAB_PUBLISH" to "Публикация лабораторной", + "LAB_LEVEL_ADD" to "Добавлен уровень", + "LAB_LEVEL_UPDATE" to "Изменён уровень", + "LAB_LEVEL_DELETE" to "Удалён уровень", + "LAB_VARIANT_ADD" to "Добавлен вариант", + "LAB_VARIANT_UPDATE" to "Изменён вариант", + "LAB_VARIANT_DELETE" to "Удалён вариант", + "STUDENT_ACADEMIC_LEAVE" to "Академический отпуск", + "STUDENT_RETURN" to "Возврат из академического отпуска", + "STUDENT_EXPEL" to "Отчисление студента", + "STUDENT_RESTORE" to "Восстановление студента", + "STUDENT_ARCHIVE" to "Студент в архиве", + "STUDENT_DELETE" to "Удалён студент", + "GROUP_ARCHIVE" to "Группа в архиве", + ) + + private val ENTITY_LABELS = + mapOf( + "GRADE" to "Оценка", + "GRADE_PUBLICATION" to "Публикация оценок", + "STUDENT" to "Студент", + "GROUP" to "Группа", + "COURSE" to "Курс", + "TEACHER" to "Преподаватель", + "SUBMISSION" to "Сдача", + "DEFENSE" to "Защита", + "LAB" to "Лабораторная", + "CONTENT" to "Материал", + "CONTENT_CHAPTER" to "Глава", + "CONTENT_SECTION" to "Раздел", + "CONTENT_ARTICLE" to "Статья", + "PROMOTION" to "Повышение", + "PROMOTION_RUN" to "Запуск повышения", + ) + + fun rows(events: List): List = + events + .sortedByDescending { occurredInstant(it.occurredAt) ?: Instant.MIN } + .map(::row) + + fun actionOptions(selected: String?): List { + val known = ACTION_LABELS.map { (value, label) -> AuditActionOption(value, label) } + val filter = selected?.trim().orEmpty() + return if (filter.isNotEmpty() && ACTION_LABELS[filter] == null) { + listOf(AuditActionOption(filter, filter)) + known + } else { + known + } + } + + private fun row(event: AuditEventDto): AuditRow = + AuditRow( + occurredMsk = DateUtils.formatMsk(event.occurredAt) ?: event.occurredAt.takeIf { it.isNotBlank() }, + actorLabel = + event.actorLogin + ?.takeIf { it.isNotBlank() } + ?: event.actorAccountId?.takeIf { it.isNotBlank() } + ?: "система", + action = event.action, + actionLabel = ACTION_LABELS[event.action] ?: event.action, + entityType = event.entityType, + entityId = event.entityId, + entityLabel = entityLabel(event.entityType, event.entityId), + courseId = event.courseId?.takeIf { it.isNotBlank() }, + studentId = event.studentId?.takeIf { it.isNotBlank() }, + details = event.details, + ) + + private fun entityLabel( + entityType: String, + entityId: String?, + ): String { + val type = entityType.trim() + val id = entityId?.trim().orEmpty() + val typeLabel = ENTITY_LABELS[type] ?: type + return when { + typeLabel.isNotBlank() && id.isNotBlank() -> "$typeLabel · $id" + typeLabel.isNotBlank() -> typeLabel + id.isNotBlank() -> id + else -> "—" + } + } + + private fun occurredInstant(value: String?): Instant? = + try { + value?.takeIf { it.isNotBlank() }?.let(Instant::parse) + } catch (_: Exception) { + null + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAudit.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAudit.kt new file mode 100644 index 0000000..ff72315 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAudit.kt @@ -0,0 +1,24 @@ +package ru.ysu.lms.web.api + +import org.springframework.core.ParameterizedTypeReference +import org.springframework.http.HttpHeaders + +fun ApiClient.listAudit( + token: String, + limit: Int = 100, + action: String? = null, +): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri { builder -> + builder.path("/api/v1/audit") + builder.queryParam("limit", limit) + if (!action.isNullOrBlank()) builder.queryParam("action", action) + builder.build() + }.header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt index 3e76e6e..95ea163 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt @@ -71,6 +71,22 @@ fun ApiClient.courseByNo( .body(CourseDetailDto::class.java) } +/** Группы, которые можно привязать к курсу (доступны преподавателю своего курса). */ +fun ApiClient.courseAvailableGroups( + token: String, + courseId: String, +): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/courses/{id}/available-groups", courseId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + /** Состав группы в рамках курса: доступен преподавателю курса и администратору. */ fun ApiClient.courseGroupStudents( token: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourseContent.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourseContent.kt new file mode 100644 index 0000000..5500f14 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourseContent.kt @@ -0,0 +1,200 @@ +package ru.ysu.lms.web.api + +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType + +/** + * Авторский контент в рамках курса: администратор или преподаватель, + * назначенный на этот курс. Все операции проверяют принадлежность курсу на стороне + * academic-service. + */ +fun ApiClient.createCourseChapter( + token: String, + courseId: String, + title: String, + slug: String?, +): ContentChapterNodeDto = + call { + client + .post() + .uri("/api/v1/courses/{courseId}/content/chapters", courseId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("title" to title, "slug" to slug)) + .retrieve() + .body(ContentChapterNodeDto::class.java) + } + +fun ApiClient.renameCourseChapter( + token: String, + courseId: String, + chapterId: String, + title: String, +) { + call { + client + .put() + .uri("/api/v1/courses/{courseId}/content/chapters/{id}", courseId, chapterId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("title" to title)) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.deleteCourseChapter( + token: String, + courseId: String, + chapterId: String, +) { + call { + client + .delete() + .uri("/api/v1/courses/{courseId}/content/chapters/{id}", courseId, chapterId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.createCourseSection( + token: String, + courseId: String, + chapterId: String, + title: String, + slug: String?, +): ContentSectionNodeDto = + call { + client + .post() + .uri { builder -> + builder + .path("/api/v1/courses/{courseId}/content/sections") + .queryParam("chapterId", chapterId) + .build(courseId) + }.header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("title" to title, "slug" to slug)) + .retrieve() + .body(ContentSectionNodeDto::class.java) + } + +fun ApiClient.renameCourseSection( + token: String, + courseId: String, + sectionId: String, + title: String, +) { + call { + client + .put() + .uri("/api/v1/courses/{courseId}/content/sections/{id}", courseId, sectionId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("title" to title)) + .retrieve() + .toBodilessEntity() + } +} + +/** Удаление раздела; при непустом [moveTo] статьи переносятся в указанный раздел. */ +fun ApiClient.deleteCourseSection( + token: String, + courseId: String, + sectionId: String, + moveTo: String? = null, +) { + call { + client + .delete() + .uri { builder -> + builder.path("/api/v1/courses/{courseId}/content/sections/{id}") + if (!moveTo.isNullOrBlank()) builder.queryParam("moveTo", moveTo) + builder.build(courseId, sectionId) + }.header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.createCourseArticle( + token: String, + courseId: String, + payload: CreateArticlePayload, +): ContentArticleDto = + call { + client + .post() + .uri("/api/v1/courses/{courseId}/content/articles", courseId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body( + mapOf( + "chapterId" to payload.chapterId, + "sectionId" to payload.sectionId, + "newChapterTitle" to payload.newChapterTitle, + "newSectionTitle" to payload.newSectionTitle, + "title" to payload.title, + "bodyMd" to payload.bodyMd, + ), + ).retrieve() + .body(ContentArticleDto::class.java) + } + +fun ApiClient.updateCourseArticle( + token: String, + courseId: String, + articleId: String, + title: String, + bodyMd: String, +) { + call { + client + .put() + .uri("/api/v1/courses/{courseId}/content/articles/{id}", courseId, articleId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("title" to title, "bodyMd" to bodyMd)) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.deleteCourseArticle( + token: String, + courseId: String, + articleId: String, +) { + call { + client + .delete() + .uri("/api/v1/courses/{courseId}/content/articles/{id}", courseId, articleId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .toBodilessEntity() + } +} + +/** Публикация или скрытие материала курса: [kind] — chapters, sections или articles. */ +fun ApiClient.setCourseContentPublished( + token: String, + courseId: String, + kind: String, + id: String, + published: Boolean, +) { + require(kind in contentKinds) { "Неизвестный тип материала: $kind" } + call { + client + .put() + .uri("/api/v1/courses/{courseId}/content/$kind/{id}/published", courseId, id) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("published" to published)) + .retrieve() + .toBodilessEntity() + } +} + +private val contentKinds = setOf("chapters", "sections", "articles") diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProgress.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProgress.kt index 6439617..7a766db 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProgress.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProgress.kt @@ -63,13 +63,19 @@ fun ApiClient.meAssignments(token: String): List { } } -fun ApiClient.listGrades(token: String): List { +fun ApiClient.listGrades( + token: String, + courseId: String? = null, +): List { val type = object : ParameterizedTypeReference>() {} return call { client .get() - .uri("/api/v1/grades") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .uri { builder -> + builder.path("/api/v1/grades") + if (!courseId.isNullOrBlank()) builder.queryParam("courseId", courseId) + builder.build() + }.header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve() .body(type) } 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 4dc80e5..a8650a5 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 @@ -328,6 +328,13 @@ data class CourseDetailDto( val groups: List = emptyList(), ) +@JsonIgnoreProperties(ignoreUnknown = true) +data class CourseGroupOptionDto( + val id: String = "", + val name: String = "", + val status: String = "ACTIVE", +) + @JsonIgnoreProperties(ignoreUnknown = true) data class CourseGroupRosterDto( val groupId: String = "", @@ -869,3 +876,17 @@ data class StudentGroupPayload( val groupId: String, val reason: String? = null, ) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AuditEventDto( + val id: String = "", + val occurredAt: String = "", + val actorAccountId: String? = null, + val actorLogin: String? = null, + val action: String = "", + val entityType: String = "", + val entityId: String? = null, + val courseId: String? = null, + val studentId: String? = null, + val details: String = "", +) diff --git a/web/src/main/kotlin/ru/ysu/lms/web/session/AdminInterceptor.kt b/web/src/main/kotlin/ru/ysu/lms/web/session/AdminInterceptor.kt index 424abd0..9a35e44 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/session/AdminInterceptor.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/session/AdminInterceptor.kt @@ -13,14 +13,57 @@ class AdminInterceptor : HandlerInterceptor { handler: Any, ): Boolean { val user = request.getSession(false)?.getAttribute(SessionKeys.USER) as? SessionUser - if (user != null && !user.isAdmin && !user.isTeacherLabsAllowed(request.requestURI)) { + // ServletPath отбрасывает matrix-параметры (`;x=1`) и не даёт обойти allowlist. + val uri = request.servletPath.ifBlank { request.requestURI } + if (user != null && !user.isAdmin && !user.isTeacherAllowed(request.method, uri)) { response.sendRedirect(if (user.isTeacher) "/teacher" else "/student") return false } return true } - /** Преподавателю доступен редактор лабораторных; остальные разделы /admin — только администратору. */ - private fun SessionUser.isTeacherLabsAllowed(uri: String): Boolean = - isTeacher && (uri == "/admin/labs" || uri.startsWith("/admin/labs/")) + /** + * Преподавателю доступны редактор лабораторных и ведомость групп своего курса. + * Всё остальное в /admin — только администратору. + */ + private fun SessionUser.isTeacherAllowed( + method: String, + uri: String, + ): Boolean = isTeacher && (isTeacherLabsAllowed(uri) || isTeacherGradebookAllowed(method, uri)) + + /** Преподавателю доступен редактор лабораторных (backend проверяет принадлежность курса). */ + private fun isTeacherLabsAllowed(uri: String): Boolean = uri == "/admin/labs" || uri.startsWith("/admin/labs/") + + private fun isTeacherGradebookAllowed( + method: String, + uri: String, + ): Boolean { + val read = method.equals("GET", ignoreCase = true) || method.equals("HEAD", ignoreCase = true) + return if (read) { + TEACHER_GET_PATHS.any { it.matches(uri) } + } else { + method.equals("POST", ignoreCase = true) && TEACHER_POST_PATHS.any { it.matches(uri) } + } + } + + private companion object { + /** GET/HEAD-страницы ведомости, защит и подсказок комментариев. */ + val TEACHER_GET_PATHS = + listOf( + Regex("^/admin/groups/[^/]+$"), + Regex("^/admin/groups/[^/]+/assign-modal$"), + Regex("^/admin/groups/[^/]+/submissions\\.zip$"), + Regex("^/admin/submissions/[^/]+/download$"), + Regex("^/admin/defense-watch$"), + Regex("^/admin/comment-templates$"), + ) + + /** POST-действия ведомости и защит; создание/импорт групп и аттестации остаются админскими. */ + val TEACHER_POST_PATHS = + listOf( + Regex("^/admin/groups/[^/]+/(grades|publish|publish-student|set-level|pin-variant|distribute)$"), + Regex("^/admin/defenses/save$"), + Regex("^/admin/defenses/[^/]+/(issue-code|reset)$"), + ) + } } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherController.kt b/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherController.kt index 39e0cba..7b59b65 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherController.kt @@ -5,11 +5,19 @@ 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.web.DateUtils import ru.ysu.lms.web.api.ApiClient +import ru.ysu.lms.web.api.ApiException +import ru.ysu.lms.web.api.CourseGroupPayload +import ru.ysu.lms.web.api.attachCourseGroup +import ru.ysu.lms.web.api.courseAvailableGroups import ru.ysu.lms.web.api.courseDetail import ru.ysu.lms.web.api.courseGroupStudents +import ru.ysu.lms.web.api.detachCourseGroup import ru.ysu.lms.web.api.listLabs import ru.ysu.lms.web.api.myCourses import ru.ysu.lms.web.session.SessionKeys @@ -48,8 +56,11 @@ class TeacherController( ): String { val token = token(session) ?: return "redirect:/login" val course = api.courseDetail(token, id) + val groups = course.groups.filter { it.removedAt == null } model.addAttribute("course", course) - model.addAttribute("groups", course.groups.filter { it.removedAt == null }) + model.addAttribute("groups", groups) + model.addAttribute("groupNames", groupNames(token, id, groups.map { it.groupId })) + model.addAttribute("availableGroups", runCatching { api.courseAvailableGroups(token, id) }.getOrDefault(emptyList())) model.addAttribute( "labs", api.listLabs(token, id).map { lab -> @@ -67,6 +78,43 @@ class TeacherController( return "teacher/course" } + @PostMapping("/courses/{id}/groups/attach") + fun attachGroup( + @PathVariable id: String, + @RequestParam groupId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + val token = token(session) ?: return "redirect:/login" + return try { + val target = groupId.trim().ifBlank { throw ApiException(400, "Выберите группу") } + api.attachCourseGroup(token, id, CourseGroupPayload(target)) + redirect.addFlashAttribute("notice", "Группа привязана к курсу") + "redirect:/teacher/courses/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/teacher/courses/$id" + } + } + + @PostMapping("/courses/{id}/groups/detach") + fun detachGroup( + @PathVariable id: String, + @RequestParam groupId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + val token = token(session) ?: return "redirect:/login" + return try { + api.detachCourseGroup(token, id, groupId) + redirect.addFlashAttribute("notice", "Группа отвязана от курса") + "redirect:/teacher/courses/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/teacher/courses/$id" + } + } + @GetMapping("/courses/{courseId}/groups/{groupId}") fun group( @PathVariable courseId: String, @@ -82,5 +130,18 @@ class TeacherController( return "teacher/group" } + /** Названия привязанных групп; при недоступности состава показываем id. */ + private fun groupNames( + token: String, + courseId: String, + groupIds: List, + ): Map = + groupIds.associate { groupId -> + groupId to + runCatching { api.courseGroupStudents(token, courseId, groupId).groupName } + .getOrDefault("") + .ifBlank { groupId } + } + private fun token(session: HttpSession): String? = session.getAttribute(SessionKeys.TOKEN) as? String } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherCourseContentController.kt b/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherCourseContentController.kt new file mode 100644 index 0000000..d141823 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherCourseContentController.kt @@ -0,0 +1,333 @@ +package ru.ysu.lms.web.teacher + +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.CreateArticlePayload +import ru.ysu.lms.web.api.courseContentArticle +import ru.ysu.lms.web.api.courseContentTree +import ru.ysu.lms.web.api.courseDetail +import ru.ysu.lms.web.api.createCourseArticle +import ru.ysu.lms.web.api.createCourseChapter +import ru.ysu.lms.web.api.createCourseSection +import ru.ysu.lms.web.api.deleteCourseArticle +import ru.ysu.lms.web.api.deleteCourseChapter +import ru.ysu.lms.web.api.deleteCourseSection +import ru.ysu.lms.web.api.renameCourseChapter +import ru.ysu.lms.web.api.renameCourseSection +import ru.ysu.lms.web.api.setCourseContentPublished +import ru.ysu.lms.web.api.updateCourseArticle +import ru.ysu.lms.web.session.SessionKeys + +/** + * Авторские материалы курса для преподавателя. Формы работают без JavaScript: + * каждое действие — обычный POST с редиректом назад на страницу курса. + */ +@Controller +@RequestMapping("/teacher/courses/{courseId}/content") +class TeacherCourseContentController( + private val api: ApiClient, + private val markdown: MarkdownRenderer, +) { + @GetMapping + fun content( + @PathVariable courseId: String, + session: HttpSession, + model: Model, + redirect: RedirectAttributes, + ): String { + val token = token(session) ?: return "redirect:/login" + return try { + model.addAttribute("course", api.courseDetail(token, courseId)) + model.addAttribute("tree", api.courseContentTree(token, courseId)) + // Рендерер отдаём в шаблон, чтобы предпросмотр статей всегда совпадал с тем, + // что увидит студент (та же санитизация markdown). + model.addAttribute("markdown", markdown) + "teacher/content" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/teacher" + } + } + + @PostMapping("/chapters") + fun createChapter( + @PathVariable courseId: String, + @RequestParam title: String, + @RequestParam(required = false) slug: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + val token = token(session) ?: return "redirect:/login" + return try { + api.createCourseChapter(token, courseId, title.trim(), slug?.trim()?.takeIf { it.isNotBlank() }) + redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана") + redirectContent(courseId) + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + redirectContent(courseId) + } + } + + @PostMapping("/chapters/{id}/rename") + fun renameChapter( + @PathVariable courseId: String, + @PathVariable id: String, + @RequestParam title: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + val token = token(session) ?: return "redirect:/login" + return try { + api.renameCourseChapter(token, courseId, id, title.trim()) + redirect.addFlashAttribute("notice", "Глава переименована") + redirectContent(courseId) + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + redirectContent(courseId) + } + } + + @PostMapping("/chapters/{id}/delete") + fun deleteChapter( + @PathVariable courseId: String, + @PathVariable id: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + val token = token(session) ?: return "redirect:/login" + return try { + api.deleteCourseChapter(token, courseId, id) + redirect.addFlashAttribute("notice", "Глава удалена вместе с содержимым") + redirectContent(courseId) + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + redirectContent(courseId) + } + } + + @Suppress("LongParameterList") + @PostMapping("/sections") + fun createSection( + @PathVariable courseId: String, + @RequestParam chapterId: String, + @RequestParam title: String, + @RequestParam(required = false) slug: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + val token = token(session) ?: return "redirect:/login" + return try { + api.createCourseSection( + token, + courseId, + chapterId, + title.trim(), + slug?.trim()?.takeIf { it.isNotBlank() }, + ) + redirect.addFlashAttribute("notice", "Раздел «${title.trim()}» создан") + redirectContent(courseId) + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + redirectContent(courseId) + } + } + + @PostMapping("/sections/{id}/rename") + fun renameSection( + @PathVariable courseId: String, + @PathVariable id: String, + @RequestParam title: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + val token = token(session) ?: return "redirect:/login" + return try { + api.renameCourseSection(token, courseId, id, title.trim()) + redirect.addFlashAttribute("notice", "Раздел переименован") + redirectContent(courseId) + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + redirectContent(courseId) + } + } + + @PostMapping("/sections/{id}/delete") + fun deleteSection( + @PathVariable courseId: String, + @PathVariable id: String, + @RequestParam(required = false) moveTo: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + val token = token(session) ?: return "redirect:/login" + return try { + api.deleteCourseSection(token, courseId, id, moveTo) + redirect.addFlashAttribute("notice", "Раздел удалён") + redirectContent(courseId) + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + redirectContent(courseId) + } + } + + @Suppress("LongParameterList") + @PostMapping("/articles") + fun createArticle( + @PathVariable courseId: String, + @RequestParam sectionId: String, + @RequestParam title: String, + @RequestParam(defaultValue = "") bodyMd: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + val token = token(session) ?: return "redirect:/login" + return try { + api.createCourseArticle( + token, + courseId, + CreateArticlePayload( + chapterId = null, + sectionId = sectionId, + newChapterTitle = null, + newSectionTitle = null, + title = title.trim(), + bodyMd = bodyMd, + ), + ) + redirect.addFlashAttribute("notice", "Статья «${title.trim()}» создана") + redirectContent(courseId) + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + redirectContent(courseId) + } + } + + @Suppress("LongParameterList") + @GetMapping("/{chapter}/{section}/{article}") + fun article( + @PathVariable courseId: String, + @PathVariable chapter: String, + @PathVariable section: String, + @PathVariable article: String, + session: HttpSession, + model: Model, + redirect: RedirectAttributes, + ): String { + val token = token(session) ?: return "redirect:/login" + return try { + val dto = api.courseContentArticle(token, courseId, chapter, section, article) + model.addAttribute("course", api.courseDetail(token, courseId)) + model.addAttribute("article", dto) + model.addAttribute("bodyHtml", markdown.render(dto.bodyMd)) + model.addAttribute("back", articlePath(courseId, chapter, section, article)) + "teacher/content-article" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + redirectContent(courseId) + } + } + + @Suppress("LongParameterList") + @PostMapping("/articles/{id}/save") + fun saveArticle( + @PathVariable courseId: String, + @PathVariable id: String, + @RequestParam title: String, + @RequestParam(defaultValue = "") bodyMd: String, + @RequestParam(required = false) back: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + val token = token(session) ?: return "redirect:/login" + return try { + api.updateCourseArticle(token, courseId, id, title.trim(), bodyMd) + redirect.addFlashAttribute("notice", "Статья сохранена") + redirectBack(back, contentPath(courseId)) + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + redirectBack(back, contentPath(courseId)) + } + } + + @PostMapping("/articles/{id}/delete") + fun deleteArticle( + @PathVariable courseId: String, + @PathVariable id: String, + @RequestParam(required = false) back: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + val token = token(session) ?: return "redirect:/login" + return try { + api.deleteCourseArticle(token, courseId, id) + redirect.addFlashAttribute("notice", "Статья удалена") + redirectContent(courseId) + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + redirectBack(back, contentPath(courseId)) + } + } + + @Suppress("LongParameterList") + @PostMapping("/{kind}/{id}/published") + fun published( + @PathVariable courseId: String, + @PathVariable kind: String, + @PathVariable id: String, + @RequestParam published: Boolean, + @RequestParam(required = false) back: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + val token = token(session) ?: return "redirect:/login" + if (kind !in setOf("chapters", "sections", "articles")) { + redirect.addFlashAttribute("error", "Неизвестный тип материала") + return redirectBack(back, contentPath(courseId)) + } + return try { + api.setCourseContentPublished(token, courseId, kind, id, published) + redirect.addFlashAttribute( + "notice", + if (published) "Материал опубликован — теперь его видят студенты" else "Материал скрыт от студентов", + ) + redirectBack(back, contentPath(courseId)) + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + redirectBack(back, contentPath(courseId)) + } + } + + private fun contentPath(courseId: String): String = "/teacher/courses/$courseId/content" + + private fun articlePath( + courseId: String, + chapter: String, + section: String, + article: String, + ): String = "${contentPath(courseId)}/$chapter/$section/$article" + + private fun redirectContent(courseId: String): String = "redirect:${contentPath(courseId)}" + + /** Возврат туда, откуда пришли: принимаем только внутренние пути. */ + private fun redirectBack( + back: String?, + fallback: String, + ): String = + if (!back.isNullOrBlank() && back.startsWith("/") && !back.startsWith("//")) { + "redirect:$back" + } else { + "redirect:$fallback" + } + + private fun token(session: HttpSession): String? = session.getAttribute(SessionKeys.TOKEN) as? String +} diff --git a/web/src/main/resources/templates/admin/audit.html b/web/src/main/resources/templates/admin/audit.html new file mode 100644 index 0000000..14eb9fb --- /dev/null +++ b/web/src/main/resources/templates/admin/audit.html @@ -0,0 +1,69 @@ + + +Аудит — LMS + +
+
+
+

Аудит

+

Журнал чувствительных действий администраторов и преподавателей.

+
+
+ +
+
+
+ + +
+
+ + +
+ + Сбросить +
+

Новые записи сверху. Можно показать от 1 до 500 записей.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Время (МСК)КтоДействиеОбъектКурсСтудентДетали
—— + — + ————
Записей аудита нет.
+
+
+ + diff --git a/web/src/main/resources/templates/admin/group.html b/web/src/main/resources/templates/admin/group.html index 5a0d2cb..66e7c38 100644 --- a/web/src/main/resources/templates/admin/group.html +++ b/web/src/main/resources/templates/admin/group.html @@ -2,7 +2,7 @@ Группа -
+

Ведомость группы

@@ -20,7 +20,7 @@ th:text="${group.status == 'ACTIVE' ? 'активна' : (group.status == 'GRADUATED' ? 'выпущена' : 'архив')}">—

-
+
Пароли @@ -45,7 +45,8 @@
Опубликованных лабораторных пока нет.
- +
@@ -59,13 +60,14 @@ —
+ @@ -79,13 +81,13 @@
⤓0
Средний балл - +
Пр. атт. @@ -123,11 +125,11 @@ —
—
-
—
@@ -177,7 +179,7 @@ — - +
@@ -195,28 +197,28 @@
Профиль - + th:href="${teacherMode} ? @{/teacher/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})} : @{/admin/courses/{courseId}/students/{studentId}(courseId=${profileCourseId}, studentId=${row.studentId})}">Профиль + -
-
-
@@ -224,14 +226,14 @@
-
-
@@ -239,7 +241,7 @@
-
@@ -265,6 +267,7 @@
+ @@ -285,7 +288,7 @@ - +

Новая аттестация

@@ -339,6 +342,7 @@ + @@ -347,6 +351,7 @@ + @@ -364,6 +369,7 @@
+
+
+ @@ -404,7 +412,7 @@ - +

Добавить студента

@@ -448,7 +456,7 @@ -
+

Студент добавлен

—

@@ -458,7 +466,7 @@
-
+

Группа — импортирована diff --git a/web/src/main/resources/templates/fragments/base.html b/web/src/main/resources/templates/fragments/base.html index 36e89ab..0537fd5 100644 --- a/web/src/main/resources/templates/fragments/base.html +++ b/web/src/main/resources/templates/fragments/base.html @@ -52,6 +52,7 @@ + diff --git a/web/src/main/resources/templates/teacher/content-article.html b/web/src/main/resources/templates/teacher/content-article.html new file mode 100644 index 0000000..0d6c23d --- /dev/null +++ b/web/src/main/resources/templates/teacher/content-article.html @@ -0,0 +1,86 @@ + + +Статья + +
+ + +
+
+

+ Статья + — +

+
+ Курс +
+
+
+ +
+ ← К материалам курса + + Студенческий вид + + + + + + + +
+ + + +
+
+ +
+
+
+ + +
+ + +
+
+ + +
+ +
+
+
+
+

Предпросмотр

+
Предпросмотр статьи
+
+
+
+
+ + diff --git a/web/src/main/resources/templates/teacher/content.html b/web/src/main/resources/templates/teacher/content.html new file mode 100644 index 0000000..079c296 --- /dev/null +++ b/web/src/main/resources/templates/teacher/content.html @@ -0,0 +1,186 @@ + + +Материалы курса + +
+ ← К курсу + +
+
+

Материалы курса

+
+ Курс №1 + · Глав: 0 +
+ +
+
+ +
+ +

Новая глава

+
+ + +
+
+ + +
+ +
+ +
+ В курсе пока нет материалов. Создайте первую главу. +
+ +
+
+
+

Глава

+ — + Разделов: 0 +
+
+
+ + + + +
+
+ + + +
+
+ + +
+
+
+ +

Разделов пока нет.

+ +
+
+
+

Раздел

+ — + Статей: 0 +
+
+
+ + + + +
+
+ + + +
+
+ + +
+
+
+ +
    +
  • + Статья + — +
    +
    + + + + +
    +
    + + + +
    +
    +
  • +
  • Статей пока нет.
  • +
+ +
+ + +
+ + +
+
+ + +
+ +
+
+ +
+ + +
+ + +
+
+ + +
+ +
+
+
+ + diff --git a/web/src/main/resources/templates/teacher/course.html b/web/src/main/resources/templates/teacher/course.html index 7ab8c18..8ad03b8 100644 --- a/web/src/main/resources/templates/teacher/course.html +++ b/web/src/main/resources/templates/teacher/course.html @@ -15,7 +15,8 @@ Групп: 0 · Лабораторных: 0

- @@ -39,16 +40,26 @@ — + th:text="${groupNames[group.groupId] ?: group.groupId}">— — + th:text="${groupNames[group.groupId] ?: group.groupId}">— — - Студенты +
+ Ведомость + Студенты +
+ + + +
+
@@ -57,7 +68,23 @@
-

Состав групп и привязку к курсу изменяет администратор.

+ +
+
+ +

Все доступные группы уже привязаны.

+
+ + +
+

Состав групп и привязку к курсу изменяет администратор.