feat(perf): пагинация аудита и bulk-группы курса
This commit is contained in:
@@ -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')")
|
||||
|
||||
@@ -52,7 +52,7 @@ data class CourseGroupRequest(
|
||||
val groupId: String,
|
||||
)
|
||||
|
||||
/** Группа, которую можно привязать к курсу: ещё не привязана и активна в identity. */
|
||||
/** Группа курса с именем и статусом из identity: привязанная или доступная для привязки. */
|
||||
data class CourseGroupOption(
|
||||
val id: String,
|
||||
val name: String,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -17,5 +17,6 @@ class AuditController(
|
||||
fun list(
|
||||
@RequestParam(defaultValue = "100") limit: Int,
|
||||
@RequestParam(required = false) action: String?,
|
||||
): List<AuditEventDto> = auditService.list(limit, action)
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
): List<AuditEventDto> = auditService.list(limit, action, page)
|
||||
}
|
||||
|
||||
@@ -38,15 +38,17 @@ class AuditService(
|
||||
fun list(
|
||||
limit: Int = DEFAULT_LIMIT,
|
||||
action: String? = null,
|
||||
page: Int = 0,
|
||||
): List<AuditEventDto> {
|
||||
val size = limit.coerceIn(1, MAX_LIMIT)
|
||||
val page = PageRequest.of(0, size)
|
||||
val pageIndex = page.coerceAtLeast(0)
|
||||
val pageable = PageRequest.of(pageIndex, size)
|
||||
val filter = action?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val found =
|
||||
if (filter == null) {
|
||||
events.findAllByOrderByOccurredAtDescIdDesc(page)
|
||||
events.findAllByOrderByOccurredAtDescIdDesc(pageable)
|
||||
} else {
|
||||
events.findAllByActionOrderByOccurredAtDescIdDesc(filter, page)
|
||||
events.findAllByActionOrderByOccurredAtDescIdDesc(filter, pageable)
|
||||
}
|
||||
return found.map(::toDto)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import ru.ysu.lms.identity.repo.AccountRepository
|
||||
import ru.ysu.lms.identity.roster.CreateGroupRequest
|
||||
import ru.ysu.lms.identity.roster.CreateStudentRequest
|
||||
import ru.ysu.lms.identity.roster.GroupService
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.UUID
|
||||
|
||||
@SpringBootTest(properties = ["lms.promotion.scheduler-enabled=false"])
|
||||
@@ -198,6 +199,33 @@ class AuditIntegrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pagination returns disjoint pages newest first`() {
|
||||
repeat(5) { index ->
|
||||
write(AuditEventRequest(action = "AUDIT_PAGE", entityType = "STUDENT", details = "page-${index + 1}"))
|
||||
}
|
||||
|
||||
val first = detailsOfPage(limit = 2, page = 0)
|
||||
val second = detailsOfPage(limit = 2, page = 1)
|
||||
val third = detailsOfPage(limit = 2, page = 2)
|
||||
|
||||
assertEquals(listOf("page-5", "page-4"), first)
|
||||
assertEquals(listOf("page-3", "page-2"), second)
|
||||
assertEquals(listOf("page-1"), third)
|
||||
assertTrue(first.intersect(second.toSet()).isEmpty())
|
||||
assertTrue((first + second).intersect(third.toSet()).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `negative page is clamped to the first page and page past the end is empty`() {
|
||||
repeat(3) { index ->
|
||||
write(AuditEventRequest(action = "AUDIT_PAGE_CLAMP", entityType = "STUDENT", details = "page-${index + 1}"))
|
||||
}
|
||||
|
||||
assertEquals(listOf("page-3", "page-2"), detailsOfPage(limit = 2, page = -5))
|
||||
assertEquals(emptyList<String>(), detailsOfPage(limit = 2, page = 1000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank action or entity type is rejected`() {
|
||||
writeExpectingBadRequest(AuditEventRequest(action = " ", entityType = "STUDENT"))
|
||||
@@ -291,6 +319,24 @@ class AuditIntegrationTest {
|
||||
assertTrue(auditEvents.findById(deleteEvent.id).isPresent)
|
||||
}
|
||||
|
||||
private fun detailsOfPage(
|
||||
limit: Int,
|
||||
page: Int,
|
||||
): List<String> {
|
||||
val body =
|
||||
mockMvc
|
||||
.get("/api/v1/audit") {
|
||||
with(admin())
|
||||
param("limit", limit.toString())
|
||||
param("page", page.toString())
|
||||
}.andExpect {
|
||||
status { isOk() }
|
||||
}.andReturn()
|
||||
.response
|
||||
.getContentAsString(StandardCharsets.UTF_8)
|
||||
return objectMapper.readTree(body).map { it.path("details").asText() }
|
||||
}
|
||||
|
||||
private fun write(request: AuditEventRequest) {
|
||||
mockMvc
|
||||
.post("/api/v1/internal/audit") {
|
||||
|
||||
Reference in New Issue
Block a user