feat(academic): преподаватель может вести лабораторные
This commit is contained in:
@@ -61,96 +61,154 @@ class LabController(
|
||||
return labService.detail(id)
|
||||
}
|
||||
|
||||
/** Копирование: нужен доступ и к исходной лабораторной, и к целевому курсу. */
|
||||
@PostMapping("/{id}/copy")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun copy(
|
||||
@PathVariable id: UUID,
|
||||
@Valid @RequestBody body: LabCopyRequest,
|
||||
): LabDetail = labService.copy(id, body)
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): LabDetail {
|
||||
accessService.requireLabAccess(id, jwt)
|
||||
accessService.requireCourseAccess(body.courseId, jwt)
|
||||
return labService.copy(id, body)
|
||||
}
|
||||
|
||||
@PostMapping("/import")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun importLab(
|
||||
@RequestParam("file") file: MultipartFile,
|
||||
@RequestParam(required = false) courseId: UUID?,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): LabImportResult {
|
||||
if (file.isEmpty) throw BadRequestException("Выберите файл")
|
||||
return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes, courseId)
|
||||
val targetCourseId = requireWritableCourse(courseId, jwt)
|
||||
return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes, targetCourseId)
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun delete(
|
||||
@PathVariable id: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
) {
|
||||
accessService.requireLabAccess(id, jwt)
|
||||
labService.delete(id)
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun update(
|
||||
@PathVariable id: UUID,
|
||||
@Valid @RequestBody body: LabUpdateRequest,
|
||||
): LabDetail = labService.update(id, body)
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): LabDetail {
|
||||
accessService.requireLabAccess(id, jwt)
|
||||
return labService.update(id, body)
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/variants")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun addVariant(
|
||||
@PathVariable id: UUID,
|
||||
@Valid @RequestBody body: VariantSaveRequest,
|
||||
): LabDetail = labService.addVariant(id, body)
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): LabDetail {
|
||||
accessService.requireLabAccess(id, jwt)
|
||||
return labService.addVariant(id, body)
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/variants/{variantId}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun updateVariant(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable variantId: UUID,
|
||||
@Valid @RequestBody body: VariantSaveRequest,
|
||||
): LabDetail = labService.updateVariant(id, variantId, body)
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): LabDetail {
|
||||
accessService.requireLabAccess(id, jwt)
|
||||
return labService.updateVariant(id, variantId, body)
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/variants/{variantId}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun deleteVariant(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable variantId: UUID,
|
||||
): LabDetail = labService.deleteVariant(id, variantId)
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): LabDetail {
|
||||
accessService.requireLabAccess(id, jwt)
|
||||
return labService.deleteVariant(id, variantId)
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/toggle-status")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun toggleStatus(
|
||||
@PathVariable id: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
) {
|
||||
accessService.requireLabAccess(id, jwt)
|
||||
labService.toggleStatus(id)
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun create(
|
||||
@Valid @RequestBody body: LabCreateRequest,
|
||||
): LabDetail = labService.create(body)
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): LabDetail {
|
||||
requireWritableCourse(body.courseId, jwt)
|
||||
return labService.create(body)
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/levels")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun addLevel(
|
||||
@PathVariable id: UUID,
|
||||
@Valid @RequestBody body: LevelSaveRequest,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): LabDetail {
|
||||
accessService.requireLabAccess(id, jwt)
|
||||
labService.addLevel(id, body)
|
||||
return labService.detail(id)
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/levels/{levelId}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun renameLevel(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable levelId: UUID,
|
||||
@Valid @RequestBody body: LevelRenameRequest,
|
||||
): LabDetail = labService.renameLevel(id, levelId, body)
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): LabDetail {
|
||||
accessService.requireLabAccess(id, jwt)
|
||||
return labService.renameLevel(id, levelId, body)
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/levels/{levelId}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||
fun deleteLevel(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable levelId: UUID,
|
||||
): LabDetail = labService.deleteLevel(id, levelId)
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): LabDetail {
|
||||
accessService.requireLabAccess(id, jwt)
|
||||
return labService.deleteLevel(id, levelId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Для записи не-администратор обязан явно указать курс: legacy-выбор единственного курса
|
||||
* остаётся только для администратора. Возвращает проверенный курс (или null для admin).
|
||||
*/
|
||||
private fun requireWritableCourse(
|
||||
courseId: UUID?,
|
||||
jwt: Jwt,
|
||||
): UUID? {
|
||||
if (courseId != null) {
|
||||
accessService.requireCourseAccess(courseId, jwt)
|
||||
return courseId
|
||||
}
|
||||
if (!accessService.isAdmin(jwt)) throw BadRequestException("Укажите курс")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,8 @@ class CourseAccessService(
|
||||
return courseService.list().filter { it.id in allowedIds }
|
||||
}
|
||||
|
||||
fun isAdmin(jwt: Jwt): Boolean = role(jwt) == ROLE_ADMIN
|
||||
|
||||
private fun activeCourseIds(courseIds: Collection<UUID>): Set<UUID> {
|
||||
if (courseIds.isEmpty()) return emptySet()
|
||||
return courses.findAllById(courseIds).filter { it.status == Course.STATUS_ACTIVE }.mapTo(HashSet()) { it.id }
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package ru.ysu.lms.academic.lab
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt
|
||||
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.web.context.WebApplicationContext
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import org.testcontainers.postgresql.PostgreSQLContainer
|
||||
import ru.ysu.lms.academic.course.CourseCreateRequest
|
||||
import ru.ysu.lms.academic.course.CourseService
|
||||
import ru.ysu.lms.academic.course.CourseTeacherRequest
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Запись в лабораторные: преподаватель управляет только лабораторными своих курсов,
|
||||
* студент не может писать вовсе, администратор не ограничен.
|
||||
*/
|
||||
@Testcontainers
|
||||
@SpringBootTest
|
||||
@Transactional
|
||||
class LabTeacherAccessIntegrationTest {
|
||||
companion object {
|
||||
@Container
|
||||
@ServiceConnection
|
||||
@JvmStatic
|
||||
val postgres = PostgreSQLContainer("postgres:18")
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private lateinit var context: WebApplicationContext
|
||||
|
||||
@Autowired
|
||||
private lateinit var courseService: CourseService
|
||||
|
||||
@Autowired
|
||||
private lateinit var labService: LabService
|
||||
|
||||
private val mockMvc: MockMvc by lazy {
|
||||
MockMvcBuilders
|
||||
.webAppContextSetup(context)
|
||||
.apply<DefaultMockMvcBuilder>(springSecurity())
|
||||
.build()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `teacher can update lab of own course`() {
|
||||
val courseId = newCourse()
|
||||
val teacherId = UUID.randomUUID()
|
||||
assignTeacher(courseId, teacherId)
|
||||
val labId = newLab(courseId)
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
put("/api/v1/labs/{id}", labId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"title":"Обновлено преподавателем"}""")
|
||||
.with(jwtFor(teacherId, "TEACHER")),
|
||||
).andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.title").value("Обновлено преподавателем"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `teacher cannot update lab of foreign course`() {
|
||||
val courseId = newCourse()
|
||||
val labId = newLab(courseId)
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
put("/api/v1/labs/{id}", labId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"title":"Чужая лабораторная"}""")
|
||||
.with(jwtFor(UUID.randomUUID(), "TEACHER")),
|
||||
).andExpect(status().isForbidden)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `teacher can create lab in own course`() {
|
||||
val courseId = newCourse()
|
||||
val teacherId = UUID.randomUUID()
|
||||
assignTeacher(courseId, teacherId)
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/v1/labs")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"number":1,"title":"Новая лабораторная","courseId":"$courseId","levels":[]}""")
|
||||
.with(jwtFor(teacherId, "TEACHER")),
|
||||
).andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.courseId").value(courseId.toString()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `teacher cannot create lab in foreign course`() {
|
||||
val courseId = newCourse()
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/v1/labs")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"number":1,"title":"Чужая лабораторная","courseId":"$courseId","levels":[]}""")
|
||||
.with(jwtFor(UUID.randomUUID(), "TEACHER")),
|
||||
).andExpect(status().isForbidden)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `teacher create without course is bad request`() {
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/v1/labs")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"number":1,"title":"Без курса","levels":[]}""")
|
||||
.with(jwtFor(UUID.randomUUID(), "TEACHER")),
|
||||
).andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.detail").value("Укажите курс"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `admin can update and create labs in any course`() {
|
||||
val courseId = newCourse()
|
||||
val labId = newLab(courseId)
|
||||
val admin = jwtFor(UUID.randomUUID(), "ADMIN")
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
put("/api/v1/labs/{id}", labId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"title":"Обновлено администратором"}""")
|
||||
.with(admin),
|
||||
).andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.title").value("Обновлено администратором"))
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/v1/labs")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"number":2,"title":"Создано администратором","courseId":"$courseId","levels":[]}""")
|
||||
.with(admin),
|
||||
).andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.courseId").value(courseId.toString()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `student cannot update or create labs`() {
|
||||
val courseId = newCourse()
|
||||
val labId = newLab(courseId)
|
||||
val student = jwtFor(UUID.randomUUID(), "STUDENT")
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
put("/api/v1/labs/{id}", labId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"title":"Студент не должен менять"}""")
|
||||
.with(student),
|
||||
).andExpect(status().isForbidden)
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/v1/labs")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"number":2,"title":"Студент не должен создавать","courseId":"$courseId","levels":[]}""")
|
||||
.with(student),
|
||||
).andExpect(status().isForbidden)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `teacher can copy own lab into own course`() {
|
||||
val sourceCourseId = newCourse()
|
||||
val targetCourseId = newCourse()
|
||||
val teacherId = UUID.randomUUID()
|
||||
assignTeacher(sourceCourseId, teacherId)
|
||||
assignTeacher(targetCourseId, teacherId)
|
||||
val sourceLabId = newLab(sourceCourseId)
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/v1/labs/{id}/copy", sourceLabId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"courseId":"$targetCourseId","number":2}""")
|
||||
.with(jwtFor(teacherId, "TEACHER")),
|
||||
).andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.courseId").value(targetCourseId.toString()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `teacher cannot copy foreign lab or into foreign course`() {
|
||||
val ownCourseId = newCourse()
|
||||
val foreignCourseId = newCourse()
|
||||
val teacherId = UUID.randomUUID()
|
||||
assignTeacher(ownCourseId, teacherId)
|
||||
val ownLabId = newLab(ownCourseId)
|
||||
val foreignLabId = newLab(foreignCourseId)
|
||||
val teacher = jwtFor(teacherId, "TEACHER")
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/v1/labs/{id}/copy", foreignLabId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"courseId":"$ownCourseId","number":2}""")
|
||||
.with(teacher),
|
||||
).andExpect(status().isForbidden)
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/v1/labs/{id}/copy", ownLabId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"courseId":"$foreignCourseId","number":2}""")
|
||||
.with(teacher),
|
||||
).andExpect(status().isForbidden)
|
||||
}
|
||||
|
||||
private fun newCourse(): UUID =
|
||||
UUID.fromString(
|
||||
courseService
|
||||
.create(CourseCreateRequest("Курс ${UUID.randomUUID()}"), UUID.randomUUID())
|
||||
.id,
|
||||
)
|
||||
|
||||
private fun assignTeacher(
|
||||
courseId: UUID,
|
||||
teacherId: UUID,
|
||||
) {
|
||||
courseService.assignTeacher(courseId, CourseTeacherRequest(teacherId.toString()))
|
||||
}
|
||||
|
||||
private fun newLab(
|
||||
courseId: UUID,
|
||||
number: Int = 1,
|
||||
): UUID =
|
||||
UUID.fromString(
|
||||
labService
|
||||
.create(
|
||||
LabCreateRequest(
|
||||
number = number,
|
||||
title = "Лабораторная ${UUID.randomUUID()}",
|
||||
courseId = courseId,
|
||||
),
|
||||
).id,
|
||||
)
|
||||
|
||||
private fun jwtFor(
|
||||
subject: UUID,
|
||||
role: String,
|
||||
) = jwt()
|
||||
.authorities(SimpleGrantedAuthority("ROLE_$role"))
|
||||
.jwt { it.subject(subject.toString()).claim("role", role) }
|
||||
}
|
||||
Reference in New Issue
Block a user