feat(web): преподавательский редактор лабораторных
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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/"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
101
web/src/test/kotlin/ru/ysu/lms/web/admin/AdminLabTemplateTest.kt
Normal file
101
web/src/test/kotlin/ru/ysu/lms/web/admin/AdminLabTemplateTest.kt
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user