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

@@ -24,14 +24,16 @@ class AdminAuditController(
fun list(
@RequestParam(required = false) action: String?,
@RequestParam(required = false) limit: String?,
@RequestParam(required = false) page: String?,
session: HttpSession,
model: Model,
): String {
val filter = action?.trim()?.takeIf { it.isNotEmpty() }
val pageSize = (limit?.trim()?.toIntOrNull() ?: DEFAULT_LIMIT).coerceIn(1, MAX_LIMIT)
val pageNumber = (page?.trim()?.toIntOrNull() ?: 0).coerceAtLeast(0)
val events: List<AuditEventDto> =
try {
api.listAudit(token(session), pageSize, filter)
api.listAudit(token(session), pageSize, filter, pageNumber)
} catch (ex: ApiException) {
model.addAttribute("error", ex.message)
emptyList()
@@ -39,6 +41,9 @@ class AdminAuditController(
model.addAttribute("events", AuditViews.rows(events))
model.addAttribute("action", filter.orEmpty())
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))
return "admin/audit"
}

View File

@@ -7,6 +7,7 @@ fun ApiClient.listAudit(
token: String,
limit: Int = 100,
action: String? = null,
page: Int = 0,
): List<AuditEventDto> {
val type = object : ParameterizedTypeReference<List<AuditEventDto>>() {}
return call {
@@ -16,6 +17,7 @@ fun ApiClient.listAudit(
builder.path("/api/v1/audit")
builder.queryParam("limit", limit)
if (!action.isNullOrBlank()) builder.queryParam("action", action)
builder.queryParam("page", page.coerceAtLeast(0))
builder.build()
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()

View File

@@ -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(
token: String,

View File

@@ -17,6 +17,7 @@ import ru.ysu.lms.web.api.attachCourseGroup
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.detachCourseGroup
import ru.ysu.lms.web.api.listLabs
import ru.ysu.lms.web.api.myCourses
@@ -57,9 +58,11 @@ class TeacherController(
val token = token(session) ?: return "redirect:/login"
val course = api.courseDetail(token, id)
val groups = course.groups.filter { it.removedAt == null }
// Названия привязанных групп приходят одним запросом; при его недоступности шаблон покажет id.
val attachedGroups = runCatching { api.courseGroups(token, id) }.getOrDefault(emptyList())
model.addAttribute("course", course)
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(
"labs",
@@ -130,18 +133,5 @@ class TeacherController(
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
}

View File

@@ -64,6 +64,18 @@
</tbody>
</table>
</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>
</body>
</html>

View File

@@ -27,41 +27,16 @@ class AdminAuditTemplateTest {
@Test
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 =
render(
"admin/audit",
mapOf(
"events" to rows,
"events" to auditRows(),
"action" to "GRADE_SET",
"limit" to 100,
"page" to 1,
"hasNext" to true,
"hasPrev" to true,
"actionOptions" to AuditViews.actionOptions("GRADE_SET"),
),
)
@@ -81,6 +56,13 @@ class AdminAuditTemplateTest {
assertThat(html).contains("c1")
assertThat(html).contains("s1")
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
@@ -92,12 +74,18 @@ class AdminAuditTemplateTest {
"events" to emptyList<AuditRow>(),
"action" to "",
"limit" to 100,
"page" to 0,
"hasNext" to false,
"hasPrev" to false,
"actionOptions" to AuditViews.actionOptions(null),
),
)
assertThat(html).contains("Аудит")
assertThat(html).contains("Записей аудита нет.")
assertThat(html).contains("Страница <span>1</span>")
assertThat(html).doesNotContain(">Назад</a>")
assertThat(html).doesNotContain(">Показать ещё</a>")
}
@Test
@@ -109,14 +97,47 @@ class AdminAuditTemplateTest {
"events" to emptyList<AuditRow>(),
"action" to "CUSTOM_ACTION",
"limit" to 100,
"page" to 2,
"hasNext" to true,
"hasPrev" to true,
"actionOptions" to AuditViews.actionOptions("CUSTOM_ACTION"),
),
)
assertThat(html).contains("value=\"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(
template: String,
variables: Map<String, Any?>,

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

View File

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

View File

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