feat(academic): преподаватель может вести лабораторные

This commit is contained in:
2026-10-27 19:49:44 +03:00
parent d6461a3a9c
commit 7c86194765
3 changed files with 341 additions and 21 deletions

View File

@@ -61,96 +61,154 @@ class LabController(
return labService.detail(id) return labService.detail(id)
} }
/** Копирование: нужен доступ и к исходной лабораторной, и к целевому курсу. */
@PostMapping("/{id}/copy") @PostMapping("/{id}/copy")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun copy( fun copy(
@PathVariable id: UUID, @PathVariable id: UUID,
@Valid @RequestBody body: LabCopyRequest, @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") @PostMapping("/import")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun importLab( fun importLab(
@RequestParam("file") file: MultipartFile, @RequestParam("file") file: MultipartFile,
@RequestParam(required = false) courseId: UUID?, @RequestParam(required = false) courseId: UUID?,
@AuthenticationPrincipal jwt: Jwt,
): LabImportResult { ): LabImportResult {
if (file.isEmpty) throw BadRequestException("Выберите файл") 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}") @DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun delete( fun delete(
@PathVariable id: UUID, @PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
) { ) {
accessService.requireLabAccess(id, jwt)
labService.delete(id) labService.delete(id)
} }
@PutMapping("/{id}") @PutMapping("/{id}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun update( fun update(
@PathVariable id: UUID, @PathVariable id: UUID,
@Valid @RequestBody body: LabUpdateRequest, @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") @PostMapping("/{id}/variants")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun addVariant( fun addVariant(
@PathVariable id: UUID, @PathVariable id: UUID,
@Valid @RequestBody body: VariantSaveRequest, @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}") @PutMapping("/{id}/variants/{variantId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun updateVariant( fun updateVariant(
@PathVariable id: UUID, @PathVariable id: UUID,
@PathVariable variantId: UUID, @PathVariable variantId: UUID,
@Valid @RequestBody body: VariantSaveRequest, @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}") @DeleteMapping("/{id}/variants/{variantId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun deleteVariant( fun deleteVariant(
@PathVariable id: UUID, @PathVariable id: UUID,
@PathVariable variantId: 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") @PostMapping("/{id}/toggle-status")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun toggleStatus( fun toggleStatus(
@PathVariable id: UUID, @PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
) { ) {
accessService.requireLabAccess(id, jwt)
labService.toggleStatus(id) labService.toggleStatus(id)
} }
@PostMapping @PostMapping
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun create( fun create(
@Valid @RequestBody body: LabCreateRequest, @Valid @RequestBody body: LabCreateRequest,
): LabDetail = labService.create(body) @AuthenticationPrincipal jwt: Jwt,
): LabDetail {
requireWritableCourse(body.courseId, jwt)
return labService.create(body)
}
@PostMapping("/{id}/levels") @PostMapping("/{id}/levels")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun addLevel( fun addLevel(
@PathVariable id: UUID, @PathVariable id: UUID,
@Valid @RequestBody body: LevelSaveRequest, @Valid @RequestBody body: LevelSaveRequest,
@AuthenticationPrincipal jwt: Jwt,
): LabDetail { ): LabDetail {
accessService.requireLabAccess(id, jwt)
labService.addLevel(id, body) labService.addLevel(id, body)
return labService.detail(id) return labService.detail(id)
} }
@PutMapping("/{id}/levels/{levelId}") @PutMapping("/{id}/levels/{levelId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun renameLevel( fun renameLevel(
@PathVariable id: UUID, @PathVariable id: UUID,
@PathVariable levelId: UUID, @PathVariable levelId: UUID,
@Valid @RequestBody body: LevelRenameRequest, @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}") @DeleteMapping("/{id}/levels/{levelId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun deleteLevel( fun deleteLevel(
@PathVariable id: UUID, @PathVariable id: UUID,
@PathVariable levelId: 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
}
} }

View File

@@ -82,6 +82,8 @@ class CourseAccessService(
return courseService.list().filter { it.id in allowedIds } 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> { private fun activeCourseIds(courseIds: Collection<UUID>): Set<UUID> {
if (courseIds.isEmpty()) return emptySet() if (courseIds.isEmpty()) return emptySet()
return courses.findAllById(courseIds).filter { it.status == Course.STATUS_ACTIVE }.mapTo(HashSet()) { it.id } return courses.findAllById(courseIds).filter { it.status == Course.STATUS_ACTIVE }.mapTo(HashSet()) { it.id }

View File

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