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