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 d00f40a..1242c30 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.CourseDto import ru.ysu.lms.web.api.LabCopyPayload import ru.ysu.lms.web.api.LabCreatePayload import ru.ysu.lms.web.api.LabUpdatePayload @@ -35,11 +36,14 @@ 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.myCourses import ru.ysu.lms.web.api.renameLevel import ru.ysu.lms.web.api.saveVariant import ru.ysu.lms.web.api.setLevel import ru.ysu.lms.web.api.toggleLabStatus import ru.ysu.lms.web.api.updateLab +import ru.ysu.lms.web.session.SessionKeys +import ru.ysu.lms.web.session.SessionUser import tools.jackson.databind.ObjectMapper @Controller @@ -58,7 +62,7 @@ class AdminLabController( 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("courses", coursesFor(session)) model.addAttribute("selectedCourseId", selectedCourseId) return "admin/labs" } @@ -134,6 +138,7 @@ class AdminLabController( ): String { val t = token(session) val lab = api.labDetail(t, id) + val user = session.getAttribute(SessionKeys.USER) as? SessionUser val submissionCount = api.labSubmissions(t, id).size val assignments = api.getLabAssignments(t, id) val variantUsage = java.util.HashMap() @@ -143,7 +148,11 @@ class AdminLabController( val occupiedVariants = allVariants.count { (variantUsage[it.id] ?: 0) > 0 } model.addAttribute("lab", lab) - model.addAttribute("courses", api.listCourses(t)) + model.addAttribute("courses", coursesFor(session)) + model.addAttribute( + "courseUrl", + lab.courseId?.let { cid -> if (user?.isAdmin == true) "/admin/courses/$cid" else "/teacher/courses/$cid" }, + ) model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd)) model.addAttribute( "deadlineMsk", @@ -175,6 +184,13 @@ class AdminLabController( return "admin/lab" } + /** Администратор видит все курсы, преподаватель — только назначенные ему. */ + private fun coursesFor(session: HttpSession): List { + val t = token(session) + val user = session.getAttribute(SessionKeys.USER) as? SessionUser + return if (user?.isAdmin == true) api.listCourses(t) else api.myCourses(t) + } + @PostMapping("/labs/{labId}/assign") fun assignVariant( @PathVariable labId: 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 f6e65e8..424abd0 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,10 +13,14 @@ class AdminInterceptor : HandlerInterceptor { handler: Any, ): Boolean { val user = request.getSession(false)?.getAttribute(SessionKeys.USER) as? SessionUser - if (user != null && !user.isAdmin) { - response.sendRedirect("/student") + if (user != null && !user.isAdmin && !user.isTeacherLabsAllowed(request.requestURI)) { + 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/")) } diff --git a/web/src/main/resources/templates/admin/lab.html b/web/src/main/resources/templates/admin/lab.html index 0f9f6ee..0144129 100644 --- a/web/src/main/resources/templates/admin/lab.html +++ b/web/src/main/resources/templates/admin/lab.html @@ -23,8 +23,9 @@
Курс: - 1. Курс + 1. Курс + 1. Курс Курс не указан
Срок: diff --git a/web/src/main/resources/templates/fragments/base.html b/web/src/main/resources/templates/fragments/base.html index bb23523..36e89ab 100644 --- a/web/src/main/resources/templates/fragments/base.html +++ b/web/src/main/resources/templates/fragments/base.html @@ -55,6 +55,7 @@ + diff --git a/web/src/test/kotlin/ru/ysu/lms/web/admin/AdminLabTemplateTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/admin/AdminLabTemplateTest.kt new file mode 100644 index 0000000..13edb67 --- /dev/null +++ b/web/src/test/kotlin/ru/ysu/lms/web/admin/AdminLabTemplateTest.kt @@ -0,0 +1,101 @@ +package ru.ysu.lms.web.admin + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.mock.web.MockServletContext +import org.thymeleaf.context.WebContext +import org.thymeleaf.spring6.SpringTemplateEngine +import org.thymeleaf.templatemode.TemplateMode +import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver +import org.thymeleaf.web.servlet.JakartaServletWebApplication +import ru.ysu.lms.web.api.LabDetailDto +import ru.ysu.lms.web.session.SessionUser +import java.util.Locale + +class AdminLabTemplateTest { + private val engine = + SpringTemplateEngine().apply { + setTemplateResolver( + ClassLoaderTemplateResolver().apply { + prefix = "templates/" + suffix = ".html" + characterEncoding = "UTF-8" + templateMode = TemplateMode.HTML + }, + ) + } + + @Test + fun `teacher lab page links course to teacher cabinet`() { + val html = render(courseUrl = "/teacher/courses/c1") + + assertThat(html).contains("href=\"/teacher/courses/c1\"") + assertThat(html).doesNotContain("/admin/courses/c1") + assertThat(html).contains("1. Программирование") + } + + @Test + fun `lab page without course link still shows course title`() { + val html = render(courseUrl = null) + + assertThat(html).contains("1. Программирование") + assertThat(html).doesNotContain("/admin/courses/c1") + assertThat(html).doesNotContain("/teacher/courses/c1") + } + + @Test + fun `teacher nav contains courses and labs links`() { + val html = render(courseUrl = "/teacher/courses/c1", role = "TEACHER") + + assertThat(html).contains(">Мои курсы") + assertThat(html).contains(">Лабораторные") + } + + private fun render( + courseUrl: String?, + role: String = "ADMIN", + ): String { + val servletContext = MockServletContext() + val application = JakartaServletWebApplication.buildApplication(servletContext) + val exchange = + application.buildExchange( + MockHttpServletRequest(servletContext), + MockHttpServletResponse(), + ) + val context = WebContext(exchange, Locale.forLanguageTag("ru")) + context.setVariable("csrfToken", "csrf") + context.setVariable("currentUser", sessionUser(role)) + context.setVariable( + "lab", + LabDetailDto( + id = "l1", + number = 1, + title = "Введение", + status = "draft", + descriptionMd = "Условие", + courseId = "c1", + courseNo = 1, + courseTitle = "Программирование", + ), + ) + context.setVariable("courses", emptyList()) + context.setVariable("courseUrl", courseUrl) + context.setVariable("descriptionHtml", "

Условие

") + context.setVariable("submissionCount", 0) + context.setVariable("totalVariants", 0) + context.setVariable("occupiedVariants", 0) + context.setVariable("variantHtml", emptyMap()) + return engine.process("admin/lab", context) + } + + private fun sessionUser(role: String): SessionUser = + SessionUser( + id = "test-id", + login = "test-login", + role = role, + name = "Тестовый пользователь", + mustChangePassword = false, + ) +} diff --git a/web/src/test/kotlin/ru/ysu/lms/web/session/AdminInterceptorTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/session/AdminInterceptorTest.kt new file mode 100644 index 0000000..bcc36a8 --- /dev/null +++ b/web/src/test/kotlin/ru/ysu/lms/web/session/AdminInterceptorTest.kt @@ -0,0 +1,89 @@ +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 AdminInterceptorTest { + @Controller + private class AdminPage { + @GetMapping("/admin/labs") + fun labs(): String = "admin/labs" + + @GetMapping("/admin/labs/{id}") + fun labDetail(): String = "admin/lab" + + @GetMapping("/admin/users") + fun users(): String = "admin/users" + } + + private val mockMvc: MockMvc = + MockMvcBuilders + .standaloneSetup(AdminPage()) + .addInterceptors(AdminInterceptor()) + .build() + + @Test + fun `teacher may open labs list`() { + mockMvc + .perform(get("/admin/labs").session(sessionFor("TEACHER"))) + .andExpect(status().isOk) + } + + @Test + fun `teacher may open lab detail`() { + mockMvc + .perform(get("/admin/labs/l1").session(sessionFor("TEACHER"))) + .andExpect(status().isOk) + } + + @Test + fun `teacher is redirected from other admin pages to teacher cabinet`() { + mockMvc + .perform(get("/admin/users").session(sessionFor("TEACHER"))) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("/teacher")) + } + + @Test + fun `student is redirected from labs to student cabinet`() { + mockMvc + .perform(get("/admin/labs").session(sessionFor("STUDENT"))) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("/student")) + } + + @Test + fun `admin may open any admin page`() { + mockMvc + .perform(get("/admin/users").session(sessionFor("ADMIN"))) + .andExpect(status().isOk) + } + + @Test + fun `anonymous user passes through to auth interceptor`() { + mockMvc + .perform(get("/admin/labs")) + .andExpect(status().isOk) + } + + private fun sessionFor(role: String): MockHttpSession = + MockHttpSession().apply { + setAttribute( + SessionKeys.USER, + SessionUser( + id = "test-id", + login = "test-login", + role = role, + name = "Тестовый пользователь", + mustChangePassword = false, + ), + ) + } +}