feat(perf): пагинация аудита и bulk-группы курса
This commit is contained in:
@@ -187,6 +187,17 @@ class CourseController(
|
|||||||
return course
|
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, ещё не привязанные к курсу. */
|
/** Активные группы identity, ещё не привязанные к курсу. */
|
||||||
@GetMapping("/{id}/available-groups")
|
@GetMapping("/{id}/available-groups")
|
||||||
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ data class CourseGroupRequest(
|
|||||||
val groupId: String,
|
val groupId: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Группа, которую можно привязать к курсу: ещё не привязана и активна в identity. */
|
/** Группа курса с именем и статусом из identity: привязанная или доступная для привязки. */
|
||||||
data class CourseGroupOption(
|
data class CourseGroupOption(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
|
|||||||
@@ -31,6 +31,22 @@ class CourseGroupService(
|
|||||||
.map { CourseGroupOption(id = it.id, name = it.name, status = it.status) }
|
.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. */
|
/** Привязывать можно только существующую активную группу identity. */
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun requireAttachable(groupIdValue: String) {
|
fun requireAttachable(groupIdValue: String) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package ru.ysu.lms.academic.course
|
package ru.ysu.lms.academic.course
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito.times
|
||||||
|
import org.mockito.Mockito.verify
|
||||||
import org.mockito.Mockito.`when`
|
import org.mockito.Mockito.`when`
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.test.context.SpringBootTest
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
@@ -108,6 +110,40 @@ class CourseGroupTeacherAccessTest {
|
|||||||
.andExpect(jsonPath("$.groups[0].removedAt").exists())
|
.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
|
@Test
|
||||||
fun `foreign teacher cannot list attach or detach groups`() {
|
fun `foreign teacher cannot list attach or detach groups`() {
|
||||||
val courseId = newCourse()
|
val courseId = newCourse()
|
||||||
@@ -119,6 +155,10 @@ class CourseGroupTeacherAccessTest {
|
|||||||
.perform(get("/api/v1/courses/{id}/available-groups", courseId).with(foreign))
|
.perform(get("/api/v1/courses/{id}/available-groups", courseId).with(foreign))
|
||||||
.andExpect(status().isForbidden)
|
.andExpect(status().isForbidden)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/v1/courses/{id}/groups", courseId).with(foreign))
|
||||||
|
.andExpect(status().isForbidden)
|
||||||
|
|
||||||
mockMvc
|
mockMvc
|
||||||
.perform(
|
.perform(
|
||||||
post("/api/v1/courses/{id}/groups", courseId)
|
post("/api/v1/courses/{id}/groups", courseId)
|
||||||
@@ -143,6 +183,10 @@ class CourseGroupTeacherAccessTest {
|
|||||||
.perform(get("/api/v1/courses/{id}/available-groups", courseId).with(student))
|
.perform(get("/api/v1/courses/{id}/available-groups", courseId).with(student))
|
||||||
.andExpect(status().isForbidden)
|
.andExpect(status().isForbidden)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/v1/courses/{id}/groups", courseId).with(student))
|
||||||
|
.andExpect(status().isForbidden)
|
||||||
|
|
||||||
mockMvc
|
mockMvc
|
||||||
.perform(
|
.perform(
|
||||||
post("/api/v1/courses/{id}/groups", courseId)
|
post("/api/v1/courses/{id}/groups", courseId)
|
||||||
@@ -177,6 +221,13 @@ class CourseGroupTeacherAccessTest {
|
|||||||
).andExpect(status().isOk)
|
).andExpect(status().isOk)
|
||||||
.andExpect(jsonPath("$.groups[0].groupId").value(groupId.toString()))
|
.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
|
mockMvc
|
||||||
.perform(delete("/api/v1/courses/{id}/groups/{groupId}", courseId, groupId).with(admin))
|
.perform(delete("/api/v1/courses/{id}/groups/{groupId}", courseId, groupId).with(admin))
|
||||||
.andExpect(status().isOk)
|
.andExpect(status().isOk)
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ class AuditController(
|
|||||||
fun list(
|
fun list(
|
||||||
@RequestParam(defaultValue = "100") limit: Int,
|
@RequestParam(defaultValue = "100") limit: Int,
|
||||||
@RequestParam(required = false) action: String?,
|
@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(
|
fun list(
|
||||||
limit: Int = DEFAULT_LIMIT,
|
limit: Int = DEFAULT_LIMIT,
|
||||||
action: String? = null,
|
action: String? = null,
|
||||||
|
page: Int = 0,
|
||||||
): List<AuditEventDto> {
|
): List<AuditEventDto> {
|
||||||
val size = limit.coerceIn(1, MAX_LIMIT)
|
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 filter = action?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
val found =
|
val found =
|
||||||
if (filter == null) {
|
if (filter == null) {
|
||||||
events.findAllByOrderByOccurredAtDescIdDesc(page)
|
events.findAllByOrderByOccurredAtDescIdDesc(pageable)
|
||||||
} else {
|
} else {
|
||||||
events.findAllByActionOrderByOccurredAtDescIdDesc(filter, page)
|
events.findAllByActionOrderByOccurredAtDescIdDesc(filter, pageable)
|
||||||
}
|
}
|
||||||
return found.map(::toDto)
|
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.CreateGroupRequest
|
||||||
import ru.ysu.lms.identity.roster.CreateStudentRequest
|
import ru.ysu.lms.identity.roster.CreateStudentRequest
|
||||||
import ru.ysu.lms.identity.roster.GroupService
|
import ru.ysu.lms.identity.roster.GroupService
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@SpringBootTest(properties = ["lms.promotion.scheduler-enabled=false"])
|
@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
|
@Test
|
||||||
fun `blank action or entity type is rejected`() {
|
fun `blank action or entity type is rejected`() {
|
||||||
writeExpectingBadRequest(AuditEventRequest(action = " ", entityType = "STUDENT"))
|
writeExpectingBadRequest(AuditEventRequest(action = " ", entityType = "STUDENT"))
|
||||||
@@ -291,6 +319,24 @@ class AuditIntegrationTest {
|
|||||||
assertTrue(auditEvents.findById(deleteEvent.id).isPresent)
|
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) {
|
private fun write(request: AuditEventRequest) {
|
||||||
mockMvc
|
mockMvc
|
||||||
.post("/api/v1/internal/audit") {
|
.post("/api/v1/internal/audit") {
|
||||||
|
|||||||
@@ -24,14 +24,16 @@ class AdminAuditController(
|
|||||||
fun list(
|
fun list(
|
||||||
@RequestParam(required = false) action: String?,
|
@RequestParam(required = false) action: String?,
|
||||||
@RequestParam(required = false) limit: String?,
|
@RequestParam(required = false) limit: String?,
|
||||||
|
@RequestParam(required = false) page: String?,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
model: Model,
|
model: Model,
|
||||||
): String {
|
): String {
|
||||||
val filter = action?.trim()?.takeIf { it.isNotEmpty() }
|
val filter = action?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
val pageSize = (limit?.trim()?.toIntOrNull() ?: DEFAULT_LIMIT).coerceIn(1, MAX_LIMIT)
|
val pageSize = (limit?.trim()?.toIntOrNull() ?: DEFAULT_LIMIT).coerceIn(1, MAX_LIMIT)
|
||||||
|
val pageNumber = (page?.trim()?.toIntOrNull() ?: 0).coerceAtLeast(0)
|
||||||
val events: List<AuditEventDto> =
|
val events: List<AuditEventDto> =
|
||||||
try {
|
try {
|
||||||
api.listAudit(token(session), pageSize, filter)
|
api.listAudit(token(session), pageSize, filter, pageNumber)
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
model.addAttribute("error", ex.message)
|
model.addAttribute("error", ex.message)
|
||||||
emptyList()
|
emptyList()
|
||||||
@@ -39,6 +41,9 @@ class AdminAuditController(
|
|||||||
model.addAttribute("events", AuditViews.rows(events))
|
model.addAttribute("events", AuditViews.rows(events))
|
||||||
model.addAttribute("action", filter.orEmpty())
|
model.addAttribute("action", filter.orEmpty())
|
||||||
model.addAttribute("limit", pageSize)
|
model.addAttribute("limit", pageSize)
|
||||||
|
model.addAttribute("page", pageNumber)
|
||||||
|
model.addAttribute("hasNext", events.size == pageSize)
|
||||||
|
model.addAttribute("hasPrev", pageNumber > 0)
|
||||||
model.addAttribute("actionOptions", AuditViews.actionOptions(filter))
|
model.addAttribute("actionOptions", AuditViews.actionOptions(filter))
|
||||||
return "admin/audit"
|
return "admin/audit"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ fun ApiClient.listAudit(
|
|||||||
token: String,
|
token: String,
|
||||||
limit: Int = 100,
|
limit: Int = 100,
|
||||||
action: String? = null,
|
action: String? = null,
|
||||||
|
page: Int = 0,
|
||||||
): List<AuditEventDto> {
|
): List<AuditEventDto> {
|
||||||
val type = object : ParameterizedTypeReference<List<AuditEventDto>>() {}
|
val type = object : ParameterizedTypeReference<List<AuditEventDto>>() {}
|
||||||
return call {
|
return call {
|
||||||
@@ -16,6 +17,7 @@ fun ApiClient.listAudit(
|
|||||||
builder.path("/api/v1/audit")
|
builder.path("/api/v1/audit")
|
||||||
builder.queryParam("limit", limit)
|
builder.queryParam("limit", limit)
|
||||||
if (!action.isNullOrBlank()) builder.queryParam("action", action)
|
if (!action.isNullOrBlank()) builder.queryParam("action", action)
|
||||||
|
builder.queryParam("page", page.coerceAtLeast(0))
|
||||||
builder.build()
|
builder.build()
|
||||||
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
.retrieve()
|
.retrieve()
|
||||||
|
|||||||
@@ -87,6 +87,22 @@ fun ApiClient.courseAvailableGroups(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Активные группы, привязанные к курсу, с названиями — одним запросом. */
|
||||||
|
fun ApiClient.courseGroups(
|
||||||
|
token: String,
|
||||||
|
courseId: String,
|
||||||
|
): List<CourseGroupOptionDto> {
|
||||||
|
val type = object : ParameterizedTypeReference<List<CourseGroupOptionDto>>() {}
|
||||||
|
return call {
|
||||||
|
client
|
||||||
|
.get()
|
||||||
|
.uri("/api/v1/courses/{id}/groups", courseId)
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.retrieve()
|
||||||
|
.body(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Состав группы в рамках курса: доступен преподавателю курса и администратору. */
|
/** Состав группы в рамках курса: доступен преподавателю курса и администратору. */
|
||||||
fun ApiClient.courseGroupStudents(
|
fun ApiClient.courseGroupStudents(
|
||||||
token: String,
|
token: String,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import ru.ysu.lms.web.api.attachCourseGroup
|
|||||||
import ru.ysu.lms.web.api.courseAvailableGroups
|
import ru.ysu.lms.web.api.courseAvailableGroups
|
||||||
import ru.ysu.lms.web.api.courseDetail
|
import ru.ysu.lms.web.api.courseDetail
|
||||||
import ru.ysu.lms.web.api.courseGroupStudents
|
import ru.ysu.lms.web.api.courseGroupStudents
|
||||||
|
import ru.ysu.lms.web.api.courseGroups
|
||||||
import ru.ysu.lms.web.api.detachCourseGroup
|
import ru.ysu.lms.web.api.detachCourseGroup
|
||||||
import ru.ysu.lms.web.api.listLabs
|
import ru.ysu.lms.web.api.listLabs
|
||||||
import ru.ysu.lms.web.api.myCourses
|
import ru.ysu.lms.web.api.myCourses
|
||||||
@@ -57,9 +58,11 @@ class TeacherController(
|
|||||||
val token = token(session) ?: return "redirect:/login"
|
val token = token(session) ?: return "redirect:/login"
|
||||||
val course = api.courseDetail(token, id)
|
val course = api.courseDetail(token, id)
|
||||||
val groups = course.groups.filter { it.removedAt == null }
|
val groups = course.groups.filter { it.removedAt == null }
|
||||||
|
// Названия привязанных групп приходят одним запросом; при его недоступности шаблон покажет id.
|
||||||
|
val attachedGroups = runCatching { api.courseGroups(token, id) }.getOrDefault(emptyList())
|
||||||
model.addAttribute("course", course)
|
model.addAttribute("course", course)
|
||||||
model.addAttribute("groups", groups)
|
model.addAttribute("groups", groups)
|
||||||
model.addAttribute("groupNames", groupNames(token, id, groups.map { it.groupId }))
|
model.addAttribute("groupNames", attachedGroups.associate { it.id to it.name.ifBlank { it.id } })
|
||||||
model.addAttribute("availableGroups", runCatching { api.courseAvailableGroups(token, id) }.getOrDefault(emptyList()))
|
model.addAttribute("availableGroups", runCatching { api.courseAvailableGroups(token, id) }.getOrDefault(emptyList()))
|
||||||
model.addAttribute(
|
model.addAttribute(
|
||||||
"labs",
|
"labs",
|
||||||
@@ -130,18 +133,5 @@ class TeacherController(
|
|||||||
return "teacher/group"
|
return "teacher/group"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Названия привязанных групп; при недоступности состава показываем id. */
|
|
||||||
private fun groupNames(
|
|
||||||
token: String,
|
|
||||||
courseId: String,
|
|
||||||
groupIds: List<String>,
|
|
||||||
): Map<String, String> =
|
|
||||||
groupIds.associate { groupId ->
|
|
||||||
groupId to
|
|
||||||
runCatching { api.courseGroupStudents(token, courseId, groupId).groupName }
|
|
||||||
.getOrDefault("")
|
|
||||||
.ifBlank { groupId }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun token(session: HttpSession): String? = session.getAttribute(SessionKeys.TOKEN) as? String
|
private fun token(session: HttpSession): String? = session.getAttribute(SessionKeys.TOKEN) as? String
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,18 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex flex-wrap items-center justify-between gap-3 mt-4" aria-label="Постраничная навигация">
|
||||||
|
<span class="meta">Страница <span th:text="${page + 1}">1</span></span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a class="btn btn-secondary"
|
||||||
|
th:if="${hasPrev}"
|
||||||
|
th:href="@{/admin/audit(action=${action},limit=${limit},page=${page - 1})}">Назад</a>
|
||||||
|
<a class="btn btn-secondary"
|
||||||
|
th:if="${hasNext}"
|
||||||
|
th:href="@{/admin/audit(action=${action},limit=${limit},page=${page + 1})}">Показать ещё</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -27,41 +27,16 @@ class AdminAuditTemplateTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `renders audit events with labels and filter`() {
|
fun `renders audit events with labels and filter`() {
|
||||||
val rows =
|
|
||||||
listOf(
|
|
||||||
AuditRow(
|
|
||||||
occurredMsk = "01.09.2026 00:00",
|
|
||||||
actorLabel = "admin",
|
|
||||||
action = "GRADE_SET",
|
|
||||||
actionLabel = "Выставлена оценка",
|
|
||||||
entityType = "GRADE",
|
|
||||||
entityId = "g1",
|
|
||||||
entityLabel = "Оценка · g1",
|
|
||||||
courseId = "c1",
|
|
||||||
studentId = "s1",
|
|
||||||
details = "Оценка 5",
|
|
||||||
),
|
|
||||||
AuditRow(
|
|
||||||
occurredMsk = "31.08.2026 23:50",
|
|
||||||
actorLabel = "система",
|
|
||||||
action = "STUDENT_TRANSFER",
|
|
||||||
actionLabel = "Перевод студента",
|
|
||||||
entityType = "STUDENT",
|
|
||||||
entityId = null,
|
|
||||||
entityLabel = "Студент",
|
|
||||||
courseId = null,
|
|
||||||
studentId = null,
|
|
||||||
details = "",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
val html =
|
val html =
|
||||||
render(
|
render(
|
||||||
"admin/audit",
|
"admin/audit",
|
||||||
mapOf(
|
mapOf(
|
||||||
"events" to rows,
|
"events" to auditRows(),
|
||||||
"action" to "GRADE_SET",
|
"action" to "GRADE_SET",
|
||||||
"limit" to 100,
|
"limit" to 100,
|
||||||
|
"page" to 1,
|
||||||
|
"hasNext" to true,
|
||||||
|
"hasPrev" to true,
|
||||||
"actionOptions" to AuditViews.actionOptions("GRADE_SET"),
|
"actionOptions" to AuditViews.actionOptions("GRADE_SET"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -81,6 +56,13 @@ class AdminAuditTemplateTest {
|
|||||||
assertThat(html).contains("c1")
|
assertThat(html).contains("c1")
|
||||||
assertThat(html).contains("s1")
|
assertThat(html).contains("s1")
|
||||||
assertThat(html).contains("Оценка 5")
|
assertThat(html).contains("Оценка 5")
|
||||||
|
assertThat(html).contains("Страница <span>2</span>")
|
||||||
|
assertThat(html).contains("Назад")
|
||||||
|
assertThat(html).contains("Показать ещё")
|
||||||
|
assertThat(html).contains("action=GRADE_SET")
|
||||||
|
assertThat(html).contains("limit=100")
|
||||||
|
assertThat(html).contains("page=0")
|
||||||
|
assertThat(html).contains("page=2")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -92,12 +74,18 @@ class AdminAuditTemplateTest {
|
|||||||
"events" to emptyList<AuditRow>(),
|
"events" to emptyList<AuditRow>(),
|
||||||
"action" to "",
|
"action" to "",
|
||||||
"limit" to 100,
|
"limit" to 100,
|
||||||
|
"page" to 0,
|
||||||
|
"hasNext" to false,
|
||||||
|
"hasPrev" to false,
|
||||||
"actionOptions" to AuditViews.actionOptions(null),
|
"actionOptions" to AuditViews.actionOptions(null),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assertThat(html).contains("Аудит")
|
assertThat(html).contains("Аудит")
|
||||||
assertThat(html).contains("Записей аудита нет.")
|
assertThat(html).contains("Записей аудита нет.")
|
||||||
|
assertThat(html).contains("Страница <span>1</span>")
|
||||||
|
assertThat(html).doesNotContain(">Назад</a>")
|
||||||
|
assertThat(html).doesNotContain(">Показать ещё</a>")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -109,14 +97,47 @@ class AdminAuditTemplateTest {
|
|||||||
"events" to emptyList<AuditRow>(),
|
"events" to emptyList<AuditRow>(),
|
||||||
"action" to "CUSTOM_ACTION",
|
"action" to "CUSTOM_ACTION",
|
||||||
"limit" to 100,
|
"limit" to 100,
|
||||||
|
"page" to 2,
|
||||||
|
"hasNext" to true,
|
||||||
|
"hasPrev" to true,
|
||||||
"actionOptions" to AuditViews.actionOptions("CUSTOM_ACTION"),
|
"actionOptions" to AuditViews.actionOptions("CUSTOM_ACTION"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assertThat(html).contains("value=\"CUSTOM_ACTION\"")
|
assertThat(html).contains("value=\"CUSTOM_ACTION\"")
|
||||||
assertThat(html).contains("CUSTOM_ACTION")
|
assertThat(html).contains("CUSTOM_ACTION")
|
||||||
|
assertThat(html).contains("page=1")
|
||||||
|
assertThat(html).contains("page=3")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun auditRows(): List<AuditRow> =
|
||||||
|
listOf(
|
||||||
|
AuditRow(
|
||||||
|
occurredMsk = "01.09.2026 00:00",
|
||||||
|
actorLabel = "admin",
|
||||||
|
action = "GRADE_SET",
|
||||||
|
actionLabel = "Выставлена оценка",
|
||||||
|
entityType = "GRADE",
|
||||||
|
entityId = "g1",
|
||||||
|
entityLabel = "Оценка · g1",
|
||||||
|
courseId = "c1",
|
||||||
|
studentId = "s1",
|
||||||
|
details = "Оценка 5",
|
||||||
|
),
|
||||||
|
AuditRow(
|
||||||
|
occurredMsk = "31.08.2026 23:50",
|
||||||
|
actorLabel = "система",
|
||||||
|
action = "STUDENT_TRANSFER",
|
||||||
|
actionLabel = "Перевод студента",
|
||||||
|
entityType = "STUDENT",
|
||||||
|
entityId = null,
|
||||||
|
entityLabel = "Студент",
|
||||||
|
courseId = null,
|
||||||
|
studentId = null,
|
||||||
|
details = "",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
private fun render(
|
private fun render(
|
||||||
template: String,
|
template: String,
|
||||||
variables: Map<String, Any?>,
|
variables: Map<String, Any?>,
|
||||||
|
|||||||
87
web/src/test/kotlin/ru/ysu/lms/web/api/ApiClientAuditTest.kt
Normal file
87
web/src/test/kotlin/ru/ysu/lms/web/api/ApiClientAuditTest.kt
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package ru.ysu.lms.web.api
|
||||||
|
|
||||||
|
import com.sun.net.httpserver.HttpExchange
|
||||||
|
import com.sun.net.httpserver.HttpServer
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import tools.jackson.databind.ObjectMapper
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
class ApiClientAuditTest {
|
||||||
|
@Test
|
||||||
|
fun `listAudit sends pagination and filter query params`() {
|
||||||
|
val query = AtomicReference<String?>()
|
||||||
|
val authorization = AtomicReference<String?>()
|
||||||
|
withAuditServer(query, authorization) { baseUrl ->
|
||||||
|
val events = ApiClient(baseUrl, ObjectMapper()).listAudit("session-token", 50, "GRADE_SET", 3)
|
||||||
|
|
||||||
|
assertThat(events).hasSize(1)
|
||||||
|
assertThat(events[0].id).isEqualTo("e1")
|
||||||
|
assertThat(authorization.get()).isEqualTo("Bearer session-token")
|
||||||
|
assertThat(query.get()).contains("limit=50")
|
||||||
|
assertThat(query.get()).contains("action=GRADE_SET")
|
||||||
|
assertThat(query.get()).contains("page=3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `listAudit defaults to first page without filter`() {
|
||||||
|
val query = AtomicReference<String?>()
|
||||||
|
val authorization = AtomicReference<String?>()
|
||||||
|
withAuditServer(query, authorization) { baseUrl ->
|
||||||
|
ApiClient(baseUrl, ObjectMapper()).listAudit("session-token")
|
||||||
|
|
||||||
|
assertThat(query.get()).contains("limit=100")
|
||||||
|
assertThat(query.get()).contains("page=0")
|
||||||
|
assertThat(query.get()).doesNotContain("action=")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun withAuditServer(
|
||||||
|
query: AtomicReference<String?>,
|
||||||
|
authorization: AtomicReference<String?>,
|
||||||
|
block: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
|
||||||
|
server.createContext("/api/v1/audit") { exchange ->
|
||||||
|
query.set(exchange.requestURI.rawQuery)
|
||||||
|
authorization.set(exchange.requestHeaders.getFirst("Authorization"))
|
||||||
|
respond(
|
||||||
|
exchange,
|
||||||
|
"""
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "e1",
|
||||||
|
"occurredAt": "2026-08-31T21:00:00Z",
|
||||||
|
"actorAccountId": "a1",
|
||||||
|
"actorLogin": "admin",
|
||||||
|
"action": "GRADE_SET",
|
||||||
|
"entityType": "GRADE",
|
||||||
|
"entityId": "g1",
|
||||||
|
"courseId": "c1",
|
||||||
|
"studentId": "s1",
|
||||||
|
"details": "Оценка 5"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
try {
|
||||||
|
block("http://127.0.0.1:${server.address.port}")
|
||||||
|
} finally {
|
||||||
|
server.stop(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun respond(
|
||||||
|
exchange: HttpExchange,
|
||||||
|
body: String,
|
||||||
|
) {
|
||||||
|
val bytes = body.toByteArray(Charsets.UTF_8)
|
||||||
|
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||||
|
exchange.sendResponseHeaders(200, bytes.size.toLong())
|
||||||
|
exchange.responseBody.use { it.write(bytes) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package ru.ysu.lms.web.api
|
||||||
|
|
||||||
|
import com.sun.net.httpserver.HttpServer
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import tools.jackson.databind.ObjectMapper
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
class ApiClientCourseGroupsTest {
|
||||||
|
@Test
|
||||||
|
fun `courseGroups loads attached groups in one authorized call`() {
|
||||||
|
val path = AtomicReference<String?>()
|
||||||
|
val authorization = AtomicReference<String?>()
|
||||||
|
withGroupsServer(path, authorization) { baseUrl ->
|
||||||
|
val groups = ApiClient(baseUrl, ObjectMapper()).courseGroups("session-token", "c1")
|
||||||
|
|
||||||
|
assertThat(path.get()).isEqualTo("/api/v1/courses/c1/groups")
|
||||||
|
assertThat(authorization.get()).isEqualTo("Bearer session-token")
|
||||||
|
assertThat(groups).hasSize(2)
|
||||||
|
assertThat(groups[0].id).isEqualTo("g1")
|
||||||
|
assertThat(groups[0].name).isEqualTo("ПИ-21БО")
|
||||||
|
assertThat(groups[1].id).isEqualTo("g2")
|
||||||
|
assertThat(groups[1].name).isEqualTo("ПИ-22БО")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun withGroupsServer(
|
||||||
|
path: AtomicReference<String?>,
|
||||||
|
authorization: AtomicReference<String?>,
|
||||||
|
block: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
|
||||||
|
server.createContext("/api/v1/courses/c1/groups") { exchange ->
|
||||||
|
path.set(exchange.requestURI.path)
|
||||||
|
authorization.set(exchange.requestHeaders.getFirst("Authorization"))
|
||||||
|
val body =
|
||||||
|
"""
|
||||||
|
[
|
||||||
|
{"id": "g1", "name": "ПИ-21БО", "status": "ACTIVE"},
|
||||||
|
{"id": "g2", "name": "ПИ-22БО", "status": "ACTIVE"}
|
||||||
|
]
|
||||||
|
""".trimIndent().toByteArray()
|
||||||
|
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||||
|
exchange.sendResponseHeaders(200, body.size.toLong())
|
||||||
|
exchange.responseBody.use { it.write(body) }
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
try {
|
||||||
|
block("http://127.0.0.1:${server.address.port}")
|
||||||
|
} finally {
|
||||||
|
server.stop(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package ru.ysu.lms.web.teacher
|
||||||
|
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import io.mockk.verify
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.mock.web.MockHttpSession
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders
|
||||||
|
import ru.ysu.lms.web.api.ApiClient
|
||||||
|
import ru.ysu.lms.web.api.ApiException
|
||||||
|
import ru.ysu.lms.web.api.CourseDetailDto
|
||||||
|
import ru.ysu.lms.web.api.CourseGroupDto
|
||||||
|
import ru.ysu.lms.web.api.CourseGroupOptionDto
|
||||||
|
import ru.ysu.lms.web.api.LabSummaryDto
|
||||||
|
import ru.ysu.lms.web.api.courseAvailableGroups
|
||||||
|
import ru.ysu.lms.web.api.courseDetail
|
||||||
|
import ru.ysu.lms.web.api.courseGroupStudents
|
||||||
|
import ru.ysu.lms.web.api.courseGroups
|
||||||
|
import ru.ysu.lms.web.api.listLabs
|
||||||
|
import ru.ysu.lms.web.session.SessionKeys
|
||||||
|
import ru.ysu.lms.web.session.SessionUser
|
||||||
|
|
||||||
|
class TeacherControllerCourseGroupsTest {
|
||||||
|
private val api = mockk<ApiClient>()
|
||||||
|
|
||||||
|
private val mockMvc: MockMvc =
|
||||||
|
MockMvcBuilders
|
||||||
|
.standaloneSetup(TeacherController(api))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
mockkStatic("ru.ysu.lms.web.api.ApiClientCourseKt", "ru.ysu.lms.web.api.ApiClientLabKt")
|
||||||
|
every { api.courseDetail("token", "c1") } returns
|
||||||
|
CourseDetailDto(
|
||||||
|
id = "c1",
|
||||||
|
courseNo = 1,
|
||||||
|
title = "Программирование",
|
||||||
|
groups =
|
||||||
|
listOf(
|
||||||
|
CourseGroupDto(groupId = "g1", addedAt = "2026-01-01T00:00:00Z"),
|
||||||
|
CourseGroupDto(groupId = "g2", addedAt = "2026-01-02T00:00:00Z", removedAt = "2026-02-01T00:00:00Z"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
every { api.listLabs("token", "c1") } returns emptyList<LabSummaryDto>()
|
||||||
|
every { api.courseAvailableGroups("token", "c1") } returns emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
unmockkAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loads group names with a single call instead of per-group requests`() {
|
||||||
|
every { api.courseGroups("token", "c1") } returns
|
||||||
|
listOf(
|
||||||
|
CourseGroupOptionDto(id = "g1", name = "ПИ-21БО", status = "ACTIVE"),
|
||||||
|
CourseGroupOptionDto(id = "g2", name = "ПИ-22БО", status = "ACTIVE"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val model = renderModel()
|
||||||
|
|
||||||
|
assertThat(model["groupNames"]).isEqualTo(mapOf("g1" to "ПИ-21БО", "g2" to "ПИ-22БО"))
|
||||||
|
assertThat(model["groups"]).isEqualTo(listOf(CourseGroupDto(groupId = "g1", addedAt = "2026-01-01T00:00:00Z")))
|
||||||
|
verify(exactly = 1) { api.courseGroups("token", "c1") }
|
||||||
|
verify(exactly = 0) { api.courseGroupStudents(any(), any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `falls back to empty names when groups endpoint is unavailable`() {
|
||||||
|
every { api.courseGroups("token", "c1") } throws ApiException(502, "Сервис недоступен")
|
||||||
|
|
||||||
|
val model = renderModel()
|
||||||
|
|
||||||
|
assertThat(model["groupNames"]).isEqualTo(emptyMap<String, String>())
|
||||||
|
verify(exactly = 0) { api.courseGroupStudents(any(), any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderModel() =
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/teacher/courses/c1").session(session()))
|
||||||
|
.andReturn()
|
||||||
|
.modelAndView!!
|
||||||
|
.model
|
||||||
|
|
||||||
|
private fun session(): MockHttpSession =
|
||||||
|
MockHttpSession().apply {
|
||||||
|
setAttribute(SessionKeys.TOKEN, "token")
|
||||||
|
setAttribute(
|
||||||
|
SessionKeys.USER,
|
||||||
|
SessionUser(
|
||||||
|
id = "t1",
|
||||||
|
login = "teacher",
|
||||||
|
role = "TEACHER",
|
||||||
|
name = "Преподаватель",
|
||||||
|
mustChangePassword = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user