feat(perf): пагинация аудита и bulk-группы курса

This commit is contained in:
2026-10-28 13:26:29 +03:00
parent 3adfba9bec
commit 4ef6c7f7ed
16 changed files with 472 additions and 49 deletions

View File

@@ -187,6 +187,17 @@ class CourseController(
return course
}
/** Активные группы курса с именами из identity — одним запросом. */
@GetMapping("/{id}/groups")
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun attachedGroups(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): List<CourseGroupOption> {
accessService.requireCourseAccess(id, jwt)
return courseGroupService.attachedGroups(id)
}
/** Активные группы identity, ещё не привязанные к курсу. */
@GetMapping("/{id}/available-groups")
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")

View File

@@ -52,7 +52,7 @@ data class CourseGroupRequest(
val groupId: String,
)
/** Группа, которую можно привязать к курсу: ещё не привязана и активна в identity. */
/** Группа курса с именем и статусом из identity: привязанная или доступная для привязки. */
data class CourseGroupOption(
val id: String,
val name: String,

View File

@@ -31,6 +31,22 @@ class CourseGroupService(
.map { CourseGroupOption(id = it.id, name = it.name, status = it.status) }
}
/**
* Активно привязанные к курсу группы с именами из identity. Один вызов вместо
* запроса имени на каждую привязку. Статус не фильтруется: если группа стала
* неактивной, она остаётся видимой в привязках курса.
*/
@Transactional(readOnly = true)
fun attachedGroups(courseId: UUID): List<CourseGroupOption> {
if (!courses.existsById(courseId)) throw NotFoundException("Курс не найден")
val attachedIds = groups.findByCourseIdAndRemovedAtIsNull(courseId).mapTo(HashSet()) { it.groupId.toString() }
return identity
.listGroups()
.filter { it.id in attachedIds }
.sortedWith(compareBy<IdentityGroupSummary> { it.name.lowercase() }.thenBy { it.id })
.map { CourseGroupOption(id = it.id, name = it.name, status = it.status) }
}
/** Привязывать можно только существующую активную группу identity. */
@Transactional(readOnly = true)
fun requireAttachable(groupIdValue: String) {

View File

@@ -1,6 +1,8 @@
package ru.ysu.lms.academic.course
import org.junit.jupiter.api.Test
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
@@ -108,6 +110,40 @@ class CourseGroupTeacherAccessTest {
.andExpect(jsonPath("$.groups[0].removedAt").exists())
}
@Test
fun `teacher gets attached groups with names in one call`() {
val courseId = newCourse()
val teacherId = UUID.randomUUID()
assignTeacher(courseId, teacherId)
val alpha = UUID.randomUUID()
val graduated = UUID.randomUUID()
val free = UUID.randomUUID()
courseService.attachGroup(courseId, CourseGroupRequest(alpha.toString()))
courseService.attachGroup(courseId, CourseGroupRequest(graduated.toString()))
`when`(identity.listGroups()).thenReturn(
listOf(
IdentityGroupSummary(graduated.toString(), "Бета", "GRADUATED"),
IdentityGroupSummary(free.toString(), "Свободна", "ACTIVE"),
IdentityGroupSummary(alpha.toString(), "Альфа", "ACTIVE"),
),
)
mockMvc
.perform(
get("/api/v1/courses/{id}/groups", courseId)
.with(jwtFor(teacherId, "TEACHER")),
).andExpect(status().isOk)
.andExpect(jsonPath("$.length()").value(2))
.andExpect(jsonPath("$[0].id").value(alpha.toString()))
.andExpect(jsonPath("$[0].name").value("Альфа"))
.andExpect(jsonPath("$[0].status").value("ACTIVE"))
.andExpect(jsonPath("$[1].id").value(graduated.toString()))
.andExpect(jsonPath("$[1].name").value("Бета"))
.andExpect(jsonPath("$[1].status").value("GRADUATED"))
verify(identity, times(1)).listGroups()
}
@Test
fun `foreign teacher cannot list attach or detach groups`() {
val courseId = newCourse()
@@ -119,6 +155,10 @@ class CourseGroupTeacherAccessTest {
.perform(get("/api/v1/courses/{id}/available-groups", courseId).with(foreign))
.andExpect(status().isForbidden)
mockMvc
.perform(get("/api/v1/courses/{id}/groups", courseId).with(foreign))
.andExpect(status().isForbidden)
mockMvc
.perform(
post("/api/v1/courses/{id}/groups", courseId)
@@ -143,6 +183,10 @@ class CourseGroupTeacherAccessTest {
.perform(get("/api/v1/courses/{id}/available-groups", courseId).with(student))
.andExpect(status().isForbidden)
mockMvc
.perform(get("/api/v1/courses/{id}/groups", courseId).with(student))
.andExpect(status().isForbidden)
mockMvc
.perform(
post("/api/v1/courses/{id}/groups", courseId)
@@ -177,6 +221,13 @@ class CourseGroupTeacherAccessTest {
).andExpect(status().isOk)
.andExpect(jsonPath("$.groups[0].groupId").value(groupId.toString()))
mockMvc
.perform(get("/api/v1/courses/{id}/groups", courseId).with(admin))
.andExpect(status().isOk)
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].id").value(groupId.toString()))
.andExpect(jsonPath("$[0].name").value("Альфа"))
mockMvc
.perform(delete("/api/v1/courses/{id}/groups/{groupId}", courseId, groupId).with(admin))
.andExpect(status().isOk)