feat(web): преподавательский редактор лабораторных

This commit is contained in:
2026-10-27 19:49:44 +03:00
parent 916046791a
commit 5affe9873b
6 changed files with 218 additions and 6 deletions

View File

@@ -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<String, Int>()
@@ -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<CourseDto> {
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,

View File

@@ -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/"))
}

View File

@@ -23,8 +23,9 @@
</div>
<div class="mb-2">
<span th:if="${lab.courseId != null}">Курс:
<a th:href="@{/admin/courses/{id}(id=${lab.courseId})}"
th:text="|${lab.courseNo}. ${lab.courseTitle}|">1. Курс</a></span>
<a th:if="${courseUrl != null}" th:href="${courseUrl}"
th:text="|${lab.courseNo}. ${lab.courseTitle}|">1. Курс</a>
<span th:if="${courseUrl == null}" th:text="|${lab.courseNo}. ${lab.courseTitle}|">1. Курс</span></span>
<span th:if="${lab.courseId == null}" class="text-muted">Курс не указан</span>
</div>
<span th:if="${deadlineMsk != null}">Срок: <span th:text="${deadlineMsk} + ' МСК'"></span></span>

View File

@@ -55,6 +55,7 @@
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/teachers}">Преподаватели</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/labs}">Лабораторные</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.teacher}"><a class="nav-link" th:href="@{/teacher}">Мои курсы</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.teacher}"><a class="nav-link" th:href="@{/admin/labs}">Лабораторные</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.student}"><a class="nav-link" th:href="@{/student/labs}">Лабораторные</a></li>
<li class="nav-item" th:if="${currentUser != null}"><a class="nav-link" th:href="@{/change-password}">Смена пароля</a></li>
</ul>

View File

@@ -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(">Мои курсы</a>")
assertThat(html).contains(">Лабораторные</a>")
}
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<Any>())
context.setVariable("courseUrl", courseUrl)
context.setVariable("descriptionHtml", "<p>Условие</p>")
context.setVariable("submissionCount", 0)
context.setVariable("totalVariants", 0)
context.setVariable("occupiedVariants", 0)
context.setVariable("variantHtml", emptyMap<String, String>())
return engine.process("admin/lab", context)
}
private fun sessionUser(role: String): SessionUser =
SessionUser(
id = "test-id",
login = "test-login",
role = role,
name = "Тестовый пользователь",
mustChangePassword = false,
)
}

View File

@@ -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,
),
)
}
}