From 9a539407f112a8f687e7eb44ece3e0af2dcb3787 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Tue, 27 Oct 2026 15:37:52 +0300 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D1=81=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D1=8B=20=D0=BA=D1=83=D1=80=D1=81=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B8=D0=B2=D1=8F=D0=B7=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BB=D0=B0=D0=B1=D0=BE=D1=80=D0=B0=D1=82=D0=BE=D1=80=D0=BD?= =?UTF-8?q?=D1=8B=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lms/web/admin/AdminCourseController.kt | 223 ++++++++++++++++++ .../kotlin/ru/ysu/lms/web/admin/AdminForms.kt | 19 ++ .../ysu/lms/web/admin/AdminLabController.kt | 40 +++- .../ru/ysu/lms/web/api/ApiClientCourse.kt | 152 ++++++++++++ .../kotlin/ru/ysu/lms/web/api/ApiClientLab.kt | 20 +- .../main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt | 86 +++++++ .../resources/templates/admin/course.html | 138 +++++++++++ .../resources/templates/admin/courses.html | 84 +++++++ .../main/resources/templates/admin/lab.html | 37 +++ .../main/resources/templates/admin/labs.html | 48 +++- .../resources/templates/fragments/base.html | 1 + 11 files changed, 837 insertions(+), 11 deletions(-) create mode 100644 web/src/main/kotlin/ru/ysu/lms/web/admin/AdminCourseController.kt create mode 100644 web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt create mode 100644 web/src/main/resources/templates/admin/course.html create mode 100644 web/src/main/resources/templates/admin/courses.html diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminCourseController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminCourseController.kt new file mode 100644 index 0000000..de1ee02 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminCourseController.kt @@ -0,0 +1,223 @@ +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.ModelAttribute +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.CourseBootstrapPayload +import ru.ysu.lms.web.api.CourseCreatePayload +import ru.ysu.lms.web.api.CourseGroupPayload +import ru.ysu.lms.web.api.CourseTeacherPayload +import ru.ysu.lms.web.api.CourseUpdatePayload +import ru.ysu.lms.web.api.assignCourseTeacher +import ru.ysu.lms.web.api.attachCourseGroup +import ru.ysu.lms.web.api.bootstrapCourses +import ru.ysu.lms.web.api.courseDetail +import ru.ysu.lms.web.api.createCourse +import ru.ysu.lms.web.api.detachCourseGroup +import ru.ysu.lms.web.api.listCourses +import ru.ysu.lms.web.api.listGroups +import ru.ysu.lms.web.api.listLabs +import ru.ysu.lms.web.api.removeCourseTeacher +import ru.ysu.lms.web.api.updateCourse +import tools.jackson.databind.ObjectMapper + +@Controller +@RequestMapping("/admin/courses") +class AdminCourseController( + api: ApiClient, + markdown: MarkdownRenderer, + mapper: ObjectMapper, +) : AdminSupport(api, markdown, mapper) { + @GetMapping + fun list( + session: HttpSession, + model: Model, + ): String { + val t = token(session) + val groups = api.listGroups(t) + model.addAttribute("courses", api.listCourses(t)) + model.addAttribute("groups", groups) + model.addAttribute("activeGroupCount", groups.count { it.status == "ACTIVE" }) + return "admin/courses" + } + + @PostMapping + fun create( + @ModelAttribute form: CreateCourseForm, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + val course = api.createCourse(token(session), CourseCreatePayload(form.title?.trim()?.takeIf { it.isNotBlank() })) + redirect.addFlashAttribute("notice", "Курс №${course.courseNo} создан") + "redirect:/admin/courses/${course.id}" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/courses" + } + + @PostMapping("/bootstrap") + fun bootstrap( + @ModelAttribute form: CreateCourseForm, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + val t = token(session) + val groupIds = api.listGroups(t).filter { it.status == "ACTIVE" }.map { it.id } + val result = + api.bootstrapCourses( + t, + CourseBootstrapPayload( + groupIds = groupIds, + title = form.title?.trim()?.takeIf { it.isNotBlank() }, + ), + ) + redirect.addFlashAttribute( + "notice", + "Курс «${result.title}» №${result.courseNo}: групп привязано ${result.attachedGroups}, " + + "лабораторных ${result.assignedLabs}, глав ${result.assignedChapters}" + + bootstrapTail(result.labsWithoutCourse, result.chaptersWithoutCourse), + ) + "redirect:/admin/courses" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/courses" + } + + private fun bootstrapTail( + labsWithoutCourse: Long, + chaptersWithoutCourse: Long, + ): String = + buildString { + if (labsWithoutCourse > 0) append("; лабораторных без курса: $labsWithoutCourse") + if (chaptersWithoutCourse > 0) append("; глав без курса: $chaptersWithoutCourse") + } + + @GetMapping("/{id}") + fun detail( + @PathVariable id: String, + session: HttpSession, + model: Model, + ): String { + val t = token(session) + val course = api.courseDetail(t, id) + val groups = api.listGroups(t) + val attachedGroups = course.groups.filter { it.removedAt == null } + val attachedIds = attachedGroups.map { it.groupId }.toSet() + model.addAttribute("course", course) + model.addAttribute("attachedGroups", attachedGroups) + model.addAttribute("availableGroups", groups.filter { it.id !in attachedIds && it.status == "ACTIVE" }) + model.addAttribute("groupNames", groups.associate { it.id to it.name }) + model.addAttribute("activeTeachers", course.teachers.filter { it.removedAt == null }) + model.addAttribute("labs", api.listLabs(t, id)) + return "admin/course" + } + + @PostMapping("/{id}/groups") + fun attachGroup( + @PathVariable id: String, + @ModelAttribute form: CourseGroupForm, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + api.attachCourseGroup(token(session), id, CourseGroupPayload(requireGroupId(form))) + redirect.addFlashAttribute("notice", "Группа привязана к курсу") + "redirect:/admin/courses/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/courses/$id" + } + + @PostMapping("/{id}/groups/{groupId}/detach") + fun detachGroup( + @PathVariable id: String, + @PathVariable groupId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + api.detachCourseGroup(token(session), id, groupId) + redirect.addFlashAttribute("notice", "Группа отвязана от курса") + "redirect:/admin/courses/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/courses/$id" + } + + @PostMapping("/{id}/teachers") + fun assignTeacher( + @PathVariable id: String, + @ModelAttribute form: CourseTeacherForm, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + api.assignCourseTeacher(token(session), id, CourseTeacherPayload(requireAccountId(form))) + redirect.addFlashAttribute("notice", "Преподаватель назначен на курс") + "redirect:/admin/courses/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/courses/$id" + } + + @PostMapping("/{id}/teachers/{accountId}/remove") + fun removeTeacher( + @PathVariable id: String, + @PathVariable accountId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + api.removeCourseTeacher(token(session), id, accountId) + redirect.addFlashAttribute("notice", "Преподаватель снят с курса") + "redirect:/admin/courses/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/courses/$id" + } + + @PostMapping("/{id}/status") + fun updateStatus( + @PathVariable id: String, + @RequestParam status: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + val normalized = normalizeStatus(status) + api.updateCourse(token(session), id, CourseUpdatePayload(status = normalized)) + val label = if (normalized == "ARCHIVED") "Курс перемещён в архив" else "Курс возвращён в работу" + redirect.addFlashAttribute("notice", label) + "redirect:/admin/courses/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/courses/$id" + } + + private fun requireGroupId(form: CourseGroupForm): String = form.groupId.trim().ifBlank { throw ApiException(400, "Выберите группу") } + + private fun requireAccountId(form: CourseTeacherForm): String = + form.accountId.trim().ifBlank { throw ApiException(400, "Укажите идентификатор преподавателя") } + + private fun normalizeStatus(value: String): String { + val status = value.trim().uppercase() + if (status !in COURSE_STATUSES) throw ApiException(400, "Недопустимый статус курса") + return status + } + + private companion object { + val COURSE_STATUSES = setOf("ACTIVE", "ARCHIVED") + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt index a2d57ee..6630f10 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt @@ -47,6 +47,25 @@ data class CreateLabForm( val status: String = "", val descriptionMd: String = "", val levels: String = "", + val courseId: String? = null, +) + +data class LabCopyForm( + val courseId: String = "", + val number: Int? = null, + val title: String? = null, +) + +data class CreateCourseForm( + val title: String? = null, +) + +data class CourseTeacherForm( + val accountId: String = "", +) + +data class CourseGroupForm( + val groupId: String = "", ) data class SaveLevelForm( diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabController.kt index e5202fd..d00f40a 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabController.kt @@ -15,6 +15,7 @@ import ru.ysu.lms.markdown.MarkdownRenderer import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.AssignVariantPayload +import ru.ysu.lms.web.api.LabCopyPayload import ru.ysu.lms.web.api.LabCreatePayload import ru.ysu.lms.web.api.LabUpdatePayload import ru.ysu.lms.web.api.LevelAssignPayload @@ -23,6 +24,7 @@ import ru.ysu.lms.web.api.LevelSavePayload import ru.ysu.lms.web.api.VariantSavePayload import ru.ysu.lms.web.api.addLevel import ru.ysu.lms.web.api.assignVariant +import ru.ysu.lms.web.api.copyLab import ru.ysu.lms.web.api.createLab import ru.ysu.lms.web.api.deleteLab import ru.ysu.lms.web.api.deleteLevel @@ -31,6 +33,7 @@ import ru.ysu.lms.web.api.getLabAssignments import ru.ysu.lms.web.api.importLab import ru.ysu.lms.web.api.labDetail import ru.ysu.lms.web.api.labSubmissions +import ru.ysu.lms.web.api.listCourses import ru.ysu.lms.web.api.listLabs import ru.ysu.lms.web.api.renameLevel import ru.ysu.lms.web.api.saveVariant @@ -48,16 +51,22 @@ class AdminLabController( ) : AdminSupport(api, markdown, mapper) { @GetMapping("/labs") fun labs( + @RequestParam(required = false) courseId: String?, session: HttpSession, model: Model, ): String { - model.addAttribute("labs", api.listLabs(token(session))) + val t = token(session) + val selectedCourseId = courseId?.trim()?.takeIf { it.isNotBlank() } + model.addAttribute("labs", api.listLabs(t, selectedCourseId)) + model.addAttribute("courses", api.listCourses(t)) + model.addAttribute("selectedCourseId", selectedCourseId) return "admin/labs" } @PostMapping("/labs/import") fun importLab( @RequestParam("file") file: MultipartFile, + @RequestParam(required = false) courseId: String?, session: HttpSession, redirect: RedirectAttributes, ): String = @@ -66,6 +75,7 @@ class AdminLabController( val result = api.importLab( token(session), + courseId?.trim()?.takeIf { it.isNotBlank() }, file.originalFilename ?: "lab.zip", file.bytes, ) @@ -133,6 +143,7 @@ class AdminLabController( val occupiedVariants = allVariants.count { (variantUsage[it.id] ?: 0) > 0 } model.addAttribute("lab", lab) + model.addAttribute("courses", api.listCourses(t)) model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd)) model.addAttribute( "deadlineMsk", @@ -218,6 +229,7 @@ class AdminLabController( status = form.status, descriptionMd = form.descriptionMd, levels = levelList, + courseId = form.courseId?.trim()?.takeIf { it.isNotBlank() }, ), ) redirect.addFlashAttribute("notice", "Лабораторная №${created.number} создана") @@ -227,6 +239,32 @@ class AdminLabController( "redirect:/admin/labs" } + @PostMapping("/labs/{id}/copy") + fun copyLab( + @PathVariable id: String, + @ModelAttribute form: LabCopyForm, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + val targetCourseId = form.courseId.trim().ifBlank { throw ApiException(400, "Выберите целевой курс") } + val created = + api.copyLab( + token(session), + id, + LabCopyPayload( + courseId = targetCourseId, + number = form.number, + title = form.title?.trim()?.takeIf { it.isNotBlank() }, + ), + ) + redirect.addFlashAttribute("notice", "Лабораторная скопирована: ЛР ${created.number}. ${created.title}") + "redirect:/admin/labs/${created.id}" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$id" + } + @PostMapping("/labs/{labId}/levels/save") fun saveLevel( @PathVariable labId: String, 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 new file mode 100644 index 0000000..8a847f1 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt @@ -0,0 +1,152 @@ +package ru.ysu.lms.web.api + +import org.springframework.core.ParameterizedTypeReference +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType + +fun ApiClient.listCourses(token: String): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/courses") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + +fun ApiClient.createCourse( + token: String, + payload: CourseCreatePayload, +): CourseDetailDto = + call { + client + .post() + .uri("/api/v1/courses") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(CourseDetailDto::class.java) + } + +fun ApiClient.courseDetail( + token: String, + courseId: String, +): CourseDetailDto = + call { + client + .get() + .uri("/api/v1/courses/{id}", courseId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(CourseDetailDto::class.java) + } + +fun ApiClient.updateCourse( + token: String, + courseId: String, + payload: CourseUpdatePayload, +): CourseDetailDto = + call { + client + .put() + .uri("/api/v1/courses/{id}", courseId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(CourseDetailDto::class.java) + } + +fun ApiClient.assignCourseTeacher( + token: String, + courseId: String, + payload: CourseTeacherPayload, +): CourseDetailDto = + call { + client + .post() + .uri("/api/v1/courses/{id}/teachers", courseId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(CourseDetailDto::class.java) + } + +fun ApiClient.removeCourseTeacher( + token: String, + courseId: String, + accountId: String, +): CourseDetailDto = + call { + client + .delete() + .uri("/api/v1/courses/{id}/teachers/{accountId}", courseId, accountId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(CourseDetailDto::class.java) + } + +fun ApiClient.attachCourseGroup( + token: String, + courseId: String, + payload: CourseGroupPayload, +): CourseDetailDto = + call { + client + .post() + .uri("/api/v1/courses/{id}/groups", courseId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(CourseDetailDto::class.java) + } + +fun ApiClient.detachCourseGroup( + token: String, + courseId: String, + groupId: String, +): CourseDetailDto = + call { + client + .delete() + .uri("/api/v1/courses/{id}/groups/{groupId}", courseId, groupId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(CourseDetailDto::class.java) + } + +fun ApiClient.bootstrapCourses( + token: String, + payload: CourseBootstrapPayload, +): CourseBootstrapResultDto = + call { + client + .post() + .uri("/api/v1/courses/bootstrap") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(CourseBootstrapResultDto::class.java) + } + +fun ApiClient.copyLab( + token: String, + labId: String, + payload: LabCopyPayload, +): LabDetailDto = + call { + client + .post() + .uri("/api/v1/labs/{id}/copy", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(LabDetailDto::class.java) + } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientLab.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientLab.kt index 37a3096..5e3c100 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientLab.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientLab.kt @@ -6,13 +6,19 @@ import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.util.LinkedMultiValueMap -fun ApiClient.listLabs(token: String): List { +fun ApiClient.listLabs( + token: String, + courseId: String? = null, +): List { val type = object : ParameterizedTypeReference>() {} return call { client .get() - .uri("/api/v1/labs") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .uri { builder -> + builder.path("/api/v1/labs") + if (!courseId.isNullOrBlank()) builder.queryParam("courseId", courseId) + builder.build() + }.header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve() .body(type) } @@ -33,6 +39,7 @@ fun ApiClient.labDetail( fun ApiClient.importLab( token: String, + courseId: String?, filename: String, bytes: ByteArray, ): LabImportResultDto { @@ -46,8 +53,11 @@ fun ApiClient.importLab( return call { client .post() - .uri("/api/v1/labs/import") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .uri { builder -> + builder.path("/api/v1/labs/import") + if (!courseId.isNullOrBlank()) builder.queryParam("courseId", courseId) + builder.build() + }.header(HttpHeaders.AUTHORIZATION, bearer(token)) .contentType(MediaType.MULTIPART_FORM_DATA) .body(parts) .retrieve() 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 c35c949..ea3471f 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 @@ -158,6 +158,9 @@ data class LabSummaryDto( val defenseAt: String? = null, val pointsMax: Any? = null, val variantCount: Int = 0, + val courseId: String? = null, + val courseNo: Long? = null, + val courseTitle: String? = null, ) @JsonIgnoreProperties(ignoreUnknown = true) @@ -250,6 +253,88 @@ data class LabDetailDto( val defenseAt: String? = null, val pointsMax: Any? = null, val levels: List = emptyList(), + val courseId: String? = null, + val courseNo: Long? = null, + val courseTitle: String? = null, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class CourseDto( + val id: String = "", + val courseNo: Long = 0, + val title: String = "", + val ownerAccountId: String = "", + val status: String = "ACTIVE", + val teacherCount: Long = 0, + val groupCount: Long = 0, + val labCount: Long = 0, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class CourseTeacherDto( + val accountId: String = "", + val assignedAt: String = "", + val removedAt: String? = null, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class CourseGroupDto( + val groupId: String = "", + val addedAt: String = "", + val removedAt: String? = null, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class CourseDetailDto( + val id: String = "", + val courseNo: Long = 0, + val title: String = "", + val ownerAccountId: String = "", + val status: String = "ACTIVE", + val createdAt: String = "", + val updatedAt: String = "", + val teachers: List = emptyList(), + val groups: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class CourseBootstrapResultDto( + val courseId: String = "", + val courseNo: Long = 0, + val title: String = "", + val attachedGroups: Int = 0, + val assignedLabs: Int = 0, + val assignedChapters: Int = 0, + val labsWithoutCourse: Long = 0, + val chaptersWithoutCourse: Long = 0, +) + +data class CourseCreatePayload( + val title: String? = null, +) + +data class CourseUpdatePayload( + val title: String? = null, + val status: String? = null, +) + +data class CourseTeacherPayload( + val accountId: String, +) + +data class CourseGroupPayload( + val groupId: String, +) + +data class CourseBootstrapPayload( + val groupIds: List = emptyList(), + val title: String? = null, +) + +data class LabCopyPayload( + val courseId: String, + val number: Int? = null, + val title: String? = null, ) @JsonIgnoreProperties(ignoreUnknown = true) @@ -382,6 +467,7 @@ data class LabCreatePayload( val status: String, val descriptionMd: String, val levels: List, + val courseId: String? = null, ) data class LevelPayload( diff --git a/web/src/main/resources/templates/admin/course.html b/web/src/main/resources/templates/admin/course.html new file mode 100644 index 0000000..ca31263 --- /dev/null +++ b/web/src/main/resources/templates/admin/course.html @@ -0,0 +1,138 @@ + + +Курс + +
+ ← Все курсы +
+
+

Курс 1

+
+ — +
+ + + +
+ Владелец: — +
+ Создан: — + · Обновлён: — +
+
+ +
+
+

Группы курса

+
+ + + + + + + + + + + + +
ГруппаДобавлена
+ — + — +
+ + +
+
Группы к курсу не привязаны.
+
+ +
+
+ +

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

+
+ + +
+
+ +
+

Преподаватели

+
+ + + + + + + + + + + + +
АккаунтНазначен
—— +
+ + +
+
Преподаватели не назначены.
+
+ +
+
+
+

UUID можно скопировать на странице «Пользователи».

+ + +
+
+
+ +
+

Лабораторные курса

+ Открыть в списке лабораторных → +
+
+ + + + + + + + + + + + +
№НазваниеСтатусВариантов
1— + — + 0 + Открыть +
В курсе пока нет лабораторных.
+
+
+ + diff --git a/web/src/main/resources/templates/admin/courses.html b/web/src/main/resources/templates/admin/courses.html new file mode 100644 index 0000000..9a7a028 --- /dev/null +++ b/web/src/main/resources/templates/admin/courses.html @@ -0,0 +1,84 @@ + + +Курсы — LMS + +
+
+

Курсы

+
+ + +
+
+ +
+

Первичная настройка курса

+

+ Курсов ещё нет. Bootstrap создаст курс «Промышленное программирование на языке C», + перенесёт в него все лабораторные и главы и привяжет все активные группы + (0). +

+
+
+
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
№НазваниеСтатусПреподавателейГруппЛабораторных
1— + — + 000 + Открыть +
Курсов пока нет.
+
+ + +
+

Новый курс

+ +
+
+
+
+
+

Номер курса присвоится автоматически. Название можно оставить пустым — + тогда будет использовано «Новый курс».

+ + +
+
+
+
+ + diff --git a/web/src/main/resources/templates/admin/lab.html b/web/src/main/resources/templates/admin/lab.html index d7cced6..0f9f6ee 100644 --- a/web/src/main/resources/templates/admin/lab.html +++ b/web/src/main/resources/templates/admin/lab.html @@ -19,6 +19,13 @@ th:text="${lab.status == 'published'} ? 'Скрыть' : 'Опубликовать'">Опубликовать + + +
+ Курс: + 1. Курс + Курс не указан
Срок: · Защита: @@ -196,6 +203,36 @@ + + +
+

Копировать лабораторную в курс

+ +
+
+
+
+ +
+
+
+
+
+
+
+

Копируются условие, уровни и варианты. Сдачи, оценки и назначения студентов + не переносятся; копия создаётся черновиком.

+ + +
+
+
diff --git a/web/src/main/resources/templates/admin/labs.html b/web/src/main/resources/templates/admin/labs.html index 5167961..94a0b9a 100644 --- a/web/src/main/resources/templates/admin/labs.html +++ b/web/src/main/resources/templates/admin/labs.html @@ -5,7 +5,16 @@

Лабораторные

-
+
+
+ + +
@@ -13,13 +22,25 @@
+
+

+ Курсов ещё нет. Сначала создайте курс на странице + «Курсы» — все лабораторные принадлежат курсу. +

+
+
- + + - +
№НазваниеВариантовСтатусДействие
№НазваниеКурсВариантовСтатусДействие
1 — + 1. Курс + — + 0
Лабораторных пока нет.
Лабораторных пока нет.
@@ -56,6 +77,14 @@
+
+ +

Сначала создайте курс на странице «Курсы».

+

@@ -66,7 +95,7 @@ deploy/IMPORT.md.

Даты сдачи и защиты после импорта задаются на карточке лабораторной.

- +
@@ -79,6 +108,15 @@
+
+ +

Сначала создайте курс на странице «Курсы».

+
@@ -103,7 +141,7 @@

Если оставить пустым, будет создан уровень «Основная группа». Варианты добавите на странице лабы.

- +
diff --git a/web/src/main/resources/templates/fragments/base.html b/web/src/main/resources/templates/fragments/base.html index c8de7ad..58dc161 100644 --- a/web/src/main/resources/templates/fragments/base.html +++ b/web/src/main/resources/templates/fragments/base.html @@ -49,6 +49,7 @@