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.ApiClient
import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.AssignVariantPayload 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.LabCopyPayload
import ru.ysu.lms.web.api.LabCreatePayload import ru.ysu.lms.web.api.LabCreatePayload
import ru.ysu.lms.web.api.LabUpdatePayload 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.labSubmissions
import ru.ysu.lms.web.api.listCourses import ru.ysu.lms.web.api.listCourses
import ru.ysu.lms.web.api.listLabs 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.renameLevel
import ru.ysu.lms.web.api.saveVariant import ru.ysu.lms.web.api.saveVariant
import ru.ysu.lms.web.api.setLevel import ru.ysu.lms.web.api.setLevel
import ru.ysu.lms.web.api.toggleLabStatus import ru.ysu.lms.web.api.toggleLabStatus
import ru.ysu.lms.web.api.updateLab 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 import tools.jackson.databind.ObjectMapper
@Controller @Controller
@@ -58,7 +62,7 @@ class AdminLabController(
val t = token(session) val t = token(session)
val selectedCourseId = courseId?.trim()?.takeIf { it.isNotBlank() } val selectedCourseId = courseId?.trim()?.takeIf { it.isNotBlank() }
model.addAttribute("labs", api.listLabs(t, selectedCourseId)) model.addAttribute("labs", api.listLabs(t, selectedCourseId))
model.addAttribute("courses", api.listCourses(t)) model.addAttribute("courses", coursesFor(session))
model.addAttribute("selectedCourseId", selectedCourseId) model.addAttribute("selectedCourseId", selectedCourseId)
return "admin/labs" return "admin/labs"
} }
@@ -134,6 +138,7 @@ class AdminLabController(
): String { ): String {
val t = token(session) val t = token(session)
val lab = api.labDetail(t, id) val lab = api.labDetail(t, id)
val user = session.getAttribute(SessionKeys.USER) as? SessionUser
val submissionCount = api.labSubmissions(t, id).size val submissionCount = api.labSubmissions(t, id).size
val assignments = api.getLabAssignments(t, id) val assignments = api.getLabAssignments(t, id)
val variantUsage = java.util.HashMap<String, Int>() val variantUsage = java.util.HashMap<String, Int>()
@@ -143,7 +148,11 @@ class AdminLabController(
val occupiedVariants = allVariants.count { (variantUsage[it.id] ?: 0) > 0 } val occupiedVariants = allVariants.count { (variantUsage[it.id] ?: 0) > 0 }
model.addAttribute("lab", lab) 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("descriptionHtml", markdown.render(lab.descriptionMd))
model.addAttribute( model.addAttribute(
"deadlineMsk", "deadlineMsk",
@@ -175,6 +184,13 @@ class AdminLabController(
return "admin/lab" 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") @PostMapping("/labs/{labId}/assign")
fun assignVariant( fun assignVariant(
@PathVariable labId: String, @PathVariable labId: String,

View File

@@ -13,10 +13,14 @@ class AdminInterceptor : HandlerInterceptor {
handler: Any, handler: Any,
): Boolean { ): Boolean {
val user = request.getSession(false)?.getAttribute(SessionKeys.USER) as? SessionUser val user = request.getSession(false)?.getAttribute(SessionKeys.USER) as? SessionUser
if (user != null && !user.isAdmin) { if (user != null && !user.isAdmin && !user.isTeacherLabsAllowed(request.requestURI)) {
response.sendRedirect("/student") response.sendRedirect(if (user.isTeacher) "/teacher" else "/student")
return false return false
} }
return true 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>
<div class="mb-2"> <div class="mb-2">
<span th:if="${lab.courseId != null}">Курс: <span th:if="${lab.courseId != null}">Курс:
<a th:href="@{/admin/courses/{id}(id=${lab.courseId})}" <a th:if="${courseUrl != null}" th:href="${courseUrl}"
th:text="|${lab.courseNo}. ${lab.courseTitle}|">1. Курс</a></span> 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> <span th:if="${lab.courseId == null}" class="text-muted">Курс не указан</span>
</div> </div>
<span th:if="${deadlineMsk != null}">Срок: <span th:text="${deadlineMsk} + ' МСК'"></span></span> <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/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.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="@{/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 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> <li class="nav-item" th:if="${currentUser != null}"><a class="nav-link" th:href="@{/change-password}">Смена пароля</a></li>
</ul> </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,
),
)
}
}