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 index de1ee02..16802d9 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminCourseController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminCourseController.kt @@ -27,6 +27,7 @@ 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.listTeachers import ru.ysu.lms.web.api.removeCourseTeacher import ru.ysu.lms.web.api.updateCourse import tools.jackson.databind.ObjectMapper @@ -115,11 +116,17 @@ class AdminCourseController( val groups = api.listGroups(t) val attachedGroups = course.groups.filter { it.removedAt == null } val attachedIds = attachedGroups.map { it.groupId }.toSet() + val teachers = api.listTeachers(t) + val activeTeachers = course.teachers.filter { it.removedAt == null } + val activeTeacherIds = activeTeachers.map { it.accountId }.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("teachers", teachers) + model.addAttribute("availableTeachers", teachers.filter { it.id !in activeTeacherIds }) + model.addAttribute("teacherNames", teachers.associate { it.id to teacherLabel(it.name, it.login) }) + model.addAttribute("activeTeachers", activeTeachers) model.addAttribute("labs", api.listLabs(t, id)) return "admin/course" } @@ -211,6 +218,11 @@ class AdminCourseController( private fun requireAccountId(form: CourseTeacherForm): String = form.accountId.trim().ifBlank { throw ApiException(400, "Укажите идентификатор преподавателя") } + private fun teacherLabel( + name: String, + login: String, + ): String = if (name.isBlank()) login else "$name ($login)" + private fun normalizeStatus(value: String): String { val status = value.trim().uppercase() if (status !in COURSE_STATUSES) throw ApiException(400, "Недопустимый статус курса") 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 6630f10..6e8a79c 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 @@ -64,6 +64,11 @@ data class CourseTeacherForm( val accountId: String = "", ) +data class TeacherCreateForm( + val login: String? = null, + val password: String? = null, +) + data class CourseGroupForm( val groupId: String = "", ) diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminTeacherController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminTeacherController.kt new file mode 100644 index 0000000..c8cdca2 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminTeacherController.kt @@ -0,0 +1,79 @@ +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.TeacherCreatePayload +import ru.ysu.lms.web.api.createTeacher +import ru.ysu.lms.web.api.listTeachers +import ru.ysu.lms.web.api.resetTeacherPassword +import tools.jackson.databind.ObjectMapper + +@Controller +@RequestMapping("/admin/teachers") +class AdminTeacherController( + api: ApiClient, + markdown: MarkdownRenderer, + mapper: ObjectMapper, +) : AdminSupport(api, markdown, mapper) { + @GetMapping + fun list( + session: HttpSession, + model: Model, + ): String { + model.addAttribute("teachers", api.listTeachers(token(session))) + return "admin/teachers" + } + + @PostMapping + fun create( + @ModelAttribute form: TeacherCreateForm, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + val created = + api.createTeacher( + token(session), + TeacherCreatePayload( + login = form.login.orEmpty().trim(), + password = form.password?.takeIf { it.isNotBlank() }, + ), + ) + redirect.addFlashAttribute("notice", "Преподаватель ${created.login} создан") + redirect.addFlashAttribute("teacherPassword", created.password) + redirect.addFlashAttribute("teacherLogin", created.login) + "redirect:/admin/teachers" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/teachers" + } + + @PostMapping("/{id}/reset-password") + fun resetPassword( + @PathVariable id: String, + @RequestParam(required = false) login: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String = + try { + val result = api.resetTeacherPassword(token(session), id) + redirect.addFlashAttribute("notice", "Пароль преподавателя сброшен") + redirect.addFlashAttribute("teacherPassword", result.password) + login?.takeIf { it.isNotBlank() }?.let { redirect.addFlashAttribute("teacherLogin", it) } + "redirect:/admin/teachers" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/teachers" + } +} 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 8a847f1..8a54935 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 @@ -16,6 +16,19 @@ fun ApiClient.listCourses(token: String): List { } } +/** Курсы текущего пользователя: для преподавателя — назначенные ему. */ +fun ApiClient.myCourses(token: String): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/courses/mine") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + fun ApiClient.createCourse( token: String, payload: CourseCreatePayload, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientTeacher.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientTeacher.kt new file mode 100644 index 0000000..5b1ea22 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientTeacher.kt @@ -0,0 +1,45 @@ +package ru.ysu.lms.web.api + +import org.springframework.core.ParameterizedTypeReference +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType + +fun ApiClient.listTeachers(token: String): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/teachers") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + +fun ApiClient.createTeacher( + token: String, + payload: TeacherCreatePayload, +): TeacherCredentialDto = + call { + client + .post() + .uri("/api/v1/teachers") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(TeacherCredentialDto::class.java) + } + +fun ApiClient.resetTeacherPassword( + token: String, + teacherId: String, +): ResetPasswordDto = + call { + client + .post() + .uri("/api/v1/teachers/{id}/reset-password", teacherId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(ResetPasswordDto::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 ea3471f..73dd998 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 @@ -130,6 +130,27 @@ data class AdminUserDto( val groupName: String? = null, ) +@JsonIgnoreProperties(ignoreUnknown = true) +data class TeacherDto( + val id: String = "", + val login: String = "", + val name: String = "", + val status: String = "ACTIVE", + val mustChangePassword: Boolean = false, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class TeacherCredentialDto( + val id: String = "", + val login: String = "", + val password: String = "", +) + +data class TeacherCreatePayload( + val login: String, + val password: String? = null, +) + @JsonIgnoreProperties(ignoreUnknown = true) data class DeleteUserDto( val id: String = "", diff --git a/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt b/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt index 5675808..b9bbab8 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt @@ -100,7 +100,7 @@ class AuthPageController( return try { api.changePassword(token, old, new, new2) session.setAttribute(SessionKeys.USER, user.copy(mustChangePassword = false)) - "redirect:" + if (user.isAdmin) "/admin" else "/student" + "redirect:" + homeFor(user) } catch (ex: ApiException) { model.addAttribute("error", ex.message) "change_password" @@ -139,6 +139,14 @@ class AuthPageController( when { auth.mustChangePassword -> "redirect:/change-password" auth.role == "ADMIN" -> "redirect:/admin" + auth.role == "TEACHER" -> "redirect:/teacher" else -> "redirect:/student" } + + private fun homeFor(user: SessionUser): String = + when { + user.isAdmin -> "/admin" + user.isTeacher -> "/teacher" + else -> "/student" + } } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt b/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt index f8b7fcb..8844f66 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt @@ -13,6 +13,7 @@ import ru.ysu.lms.web.session.AuthInterceptor import ru.ysu.lms.web.session.MustChangePasswordInterceptor import ru.ysu.lms.web.session.SessionKeys import ru.ysu.lms.web.session.SessionUser +import ru.ysu.lms.web.session.TeacherInterceptor /** Динамические страницы не кэшируются: браузер всегда берёт свежую разметку со свежими ссылками на css/js. */ class NoCachePageInterceptor : HandlerInterceptor { @@ -30,6 +31,7 @@ class NoCachePageInterceptor : HandlerInterceptor { class WebConfig( private val authInterceptor: AuthInterceptor, private val adminInterceptor: AdminInterceptor, + private val teacherInterceptor: TeacherInterceptor, private val csrfInterceptor: CsrfInterceptor, private val mustChangePasswordInterceptor: MustChangePasswordInterceptor, private val formFeedbackInterceptor: FormFeedbackInterceptor, @@ -55,10 +57,13 @@ class WebConfig( ) registry .addInterceptor(authInterceptor) - .addPathPatterns("/admin/**", "/student/**", "/change-password") + .addPathPatterns("/admin/**", "/student/**", "/teacher/**", "/change-password") registry .addInterceptor(adminInterceptor) .addPathPatterns("/admin/**") + registry + .addInterceptor(teacherInterceptor) + .addPathPatterns("/teacher/**") } } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/session/SessionUser.kt b/web/src/main/kotlin/ru/ysu/lms/web/session/SessionUser.kt index 5fe91b0..e65ef44 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/session/SessionUser.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/session/SessionUser.kt @@ -8,6 +8,7 @@ data class SessionUser( val mustChangePassword: Boolean, ) { val isAdmin: Boolean get() = role == "ADMIN" + val isTeacher: Boolean get() = role == "TEACHER" val isStudent: Boolean get() = role == "STUDENT" } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/session/TeacherInterceptor.kt b/web/src/main/kotlin/ru/ysu/lms/web/session/TeacherInterceptor.kt new file mode 100644 index 0000000..d26d0b1 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/session/TeacherInterceptor.kt @@ -0,0 +1,27 @@ +package ru.ysu.lms.web.session + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.stereotype.Component +import org.springframework.web.servlet.HandlerInterceptor + +/** Кабинет преподавателя доступен преподавателям и администраторам. */ +@Component +class TeacherInterceptor : HandlerInterceptor { + override fun preHandle( + request: HttpServletRequest, + response: HttpServletResponse, + handler: Any, + ): Boolean { + val user = request.getSession(false)?.getAttribute(SessionKeys.USER) as? SessionUser + if (user == null) { + response.sendRedirect("/login") + return false + } + if (!user.isTeacher && !user.isAdmin) { + response.sendRedirect("/student") + return false + } + return true + } +} 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 new file mode 100644 index 0000000..fdaf479 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherController.kt @@ -0,0 +1,70 @@ +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.RequestMapping +import ru.ysu.lms.web.DateUtils +import ru.ysu.lms.web.api.ApiClient +import ru.ysu.lms.web.api.courseDetail +import ru.ysu.lms.web.api.listLabs +import ru.ysu.lms.web.api.myCourses +import ru.ysu.lms.web.session.SessionKeys + +@Controller +@RequestMapping("/teacher") +class TeacherController( + private val api: ApiClient, +) { + /** Строка лабораторной для кабинета преподавателя: только чтение. */ + data class LabRow( + val id: String, + val number: Int, + val title: String, + val status: String, + val variantCount: Int, + val deadlineMsk: String?, + val defenseMsk: String?, + ) + + @GetMapping + fun dashboard( + session: HttpSession, + model: Model, + ): String { + val token = token(session) ?: return "redirect:/login" + model.addAttribute("courses", api.myCourses(token)) + return "teacher/dashboard" + } + + @GetMapping("/courses/{id}") + fun course( + @PathVariable id: String, + session: HttpSession, + model: Model, + ): String { + val token = token(session) ?: return "redirect:/login" + val course = api.courseDetail(token, id) + model.addAttribute("course", course) + model.addAttribute("groups", course.groups.filter { it.removedAt == null }) + model.addAttribute( + "labs", + api.listLabs(token, id).map { lab -> + LabRow( + id = lab.id, + number = lab.number, + title = lab.title, + status = lab.status, + variantCount = lab.variantCount, + deadlineMsk = DateUtils.formatMsk(lab.deadlineAt), + defenseMsk = DateUtils.formatMsk(lab.defenseAt), + ) + }, + ) + return "teacher/course" + } + + private fun token(session: HttpSession): String? = session.getAttribute(SessionKeys.TOKEN) as? String +} diff --git a/web/src/main/resources/templates/admin/course.html b/web/src/main/resources/templates/admin/course.html index ca31263..924166e 100644 --- a/web/src/main/resources/templates/admin/course.html +++ b/web/src/main/resources/templates/admin/course.html @@ -75,10 +75,14 @@

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

- + - +
АккаунтНазначен
ПреподавательНазначен
— + — +
—
+
—
-
-
-

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

- +
+ +

+ Преподавателей ещё нет. + Создайте первого на странице «Преподаватели». +

+

+ Все преподаватели уже назначены на курс. +

+

+ Нет нужного? + Создать преподавателя на странице «Преподаватели». +

+
+
diff --git a/web/src/main/resources/templates/admin/teachers.html b/web/src/main/resources/templates/admin/teachers.html new file mode 100644 index 0000000..74ad13a --- /dev/null +++ b/web/src/main/resources/templates/admin/teachers.html @@ -0,0 +1,70 @@ + + +Преподаватели — LMS + +
+

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

+ +
+

Новый преподаватель

+
+
+ + +
+
+ + +
+
+

+ Логин — минимум 3 символа. Если пароль не указан, сервис сгенерирует его. + Одноразовый пароль будет показан один раз после создания — передайте его преподавателю. +

+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + +
ПреподавательЛогинСтатусДействия
+ — +
—
+
— + — + временный пароль + +
+ + + +
+
Преподавателей пока нет. Создайте первого в форме выше.
+
+
+ + diff --git a/web/src/main/resources/templates/fragments/base.html b/web/src/main/resources/templates/fragments/base.html index 58dc161..86c0877 100644 --- a/web/src/main/resources/templates/fragments/base.html +++ b/web/src/main/resources/templates/fragments/base.html @@ -51,13 +51,16 @@ + - + +
diff --git a/web/src/main/resources/templates/teacher/course.html b/web/src/main/resources/templates/teacher/course.html new file mode 100644 index 0000000..cbeb5de --- /dev/null +++ b/web/src/main/resources/templates/teacher/course.html @@ -0,0 +1,92 @@ + + +Курс + +
+ ← Мои курсы + +
+
+

Курс

+
+ — + Групп: 0 + · Лабораторных: 0 +
+
+
+ +
+
+

Группы курса

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

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

+
+ +
+

Сведения о курсе

+
+

Создан: —

+

Обновлён: —

+

+ Преподавателей на курсе: 0 +

+
+
+
+ +

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

+
+ + + + + + + + + + + + + + + + + +
№НазваниеСтатусВариантовСрок сдачиЗащита
1 + — + — + + — + 0——
В курсе пока нет лабораторных.
+
+
+ + diff --git a/web/src/main/resources/templates/teacher/dashboard.html b/web/src/main/resources/templates/teacher/dashboard.html new file mode 100644 index 0000000..59439e1 --- /dev/null +++ b/web/src/main/resources/templates/teacher/dashboard.html @@ -0,0 +1,43 @@ + + +Мои курсы — LMS + +
+

Здравствуйте, преподаватель

+

Курсы, на которые вы назначены.

+ +
+
+
+
+

+ Курс +

+
+ — + групп: 0 + лабораторных: 0 +
+ +
+
+
+
+
+ Вам пока не назначено ни одного курса. Обратитесь к администратору. +
+
+
+ + +
+ + diff --git a/web/src/test/kotlin/ru/ysu/lms/web/session/TeacherInterceptorTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/session/TeacherInterceptorTest.kt new file mode 100644 index 0000000..49af1f0 --- /dev/null +++ b/web/src/test/kotlin/ru/ysu/lms/web/session/TeacherInterceptorTest.kt @@ -0,0 +1,69 @@ +package ru.ysu.lms.web.session + +import org.junit.jupiter.api.Test +import org.springframework.mock.web.MockHttpSession +import org.springframework.stereotype.Controller +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.web.bind.annotation.GetMapping + +class TeacherInterceptorTest { + @Controller + private class TeacherPage { + @GetMapping("/teacher/test") + fun page(): String = "teacher/dashboard" + } + + private val mockMvc: MockMvc = + MockMvcBuilders + .standaloneSetup(TeacherPage()) + .addInterceptors(TeacherInterceptor()) + .build() + + @Test + fun `teacher may open teacher pages`() { + mockMvc + .perform(get("/teacher/test").session(sessionFor("TEACHER"))) + .andExpect(status().isOk) + } + + @Test + fun `admin may open teacher pages`() { + mockMvc + .perform(get("/teacher/test").session(sessionFor("ADMIN"))) + .andExpect(status().isOk) + } + + @Test + fun `student is redirected to student cabinet`() { + mockMvc + .perform(get("/teacher/test").session(sessionFor("STUDENT"))) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("/student")) + } + + @Test + fun `anonymous user is redirected to login`() { + mockMvc + .perform(get("/teacher/test")) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("/login")) + } + + private fun sessionFor(role: String): MockHttpSession = + MockHttpSession().apply { + setAttribute( + SessionKeys.USER, + SessionUser( + id = "test-id", + login = "test-login", + role = role, + name = "Тестовый пользователь", + mustChangePassword = false, + ), + ) + } +}