feat: закрыть остаточные P1/P2 по профилю, когорте и ведомости

- профиль приведён к docs/09: variant/level, updatedBy/name, лимит попыток
- V14: NOT NULL и полная уникальность когорты, preflight legacy-групп
- возврат группы из архива и история членства на странице группы
- пагинация групп и плоского списка материалов
- пакетные сдачи ведомости одним запросом
- XLSX-preflight импорта и audit-тесты ростера
- LDD: 66 claims, 398 тестов, release PASS
This commit is contained in:
2026-10-29 20:51:27 +03:00
parent 7ef5c866a3
commit 82394cc688
48 changed files with 1196 additions and 156 deletions

View File

@@ -359,24 +359,31 @@ class ContentService(
articles.save(a)
}
/** Плоский список материалов; [limit] = 0 — все записи (совместимость API). */
@Transactional(readOnly = true)
fun listAll(): List<ContentArticleView> {
fun listAll(
offset: Int = 0,
limit: Int = 0,
): List<ContentArticleView> {
val chById = chapters.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
val secById = sections.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
return articles.findAllByOrderByPositionAscIdAsc().mapNotNull { a ->
val sec = secById[a.sectionId] ?: return@mapNotNull null
val ch = chById[sec.chapterId] ?: return@mapNotNull null
ContentArticleView(
id = a.id.toString(),
chapterSlug = ch.slug,
chapterTitle = ch.title,
sectionSlug = sec.slug,
sectionTitle = sec.title,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
)
}
val all =
articles.findAllByOrderByPositionAscIdAsc().mapNotNull { a ->
val sec = secById[a.sectionId] ?: return@mapNotNull null
val ch = chById[sec.chapterId] ?: return@mapNotNull null
ContentArticleView(
id = a.id.toString(),
chapterSlug = ch.slug,
chapterTitle = ch.title,
sectionSlug = sec.slug,
sectionTitle = sec.title,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
)
}
if (limit <= 0) return all
return all.drop(offset.coerceAtLeast(0)).take(limit.coerceIn(1, MAX_ARTICLE_PAGE))
}
@Transactional
@@ -385,6 +392,10 @@ class ContentService(
bytes: ByteArray,
sync: Boolean = true,
): ContentImportResult = importer.importZip(filename, bytes, sync)
private companion object {
const val MAX_ARTICLE_PAGE = 500
}
}
/**

View File

@@ -158,7 +158,10 @@ class ContentAdminController(
}
@GetMapping("/articles")
fun list() = contentService.listAll()
fun list(
@RequestParam(defaultValue = "0") offset: Int,
@RequestParam(defaultValue = "0") limit: Int,
) = contentService.listAll(offset, limit)
@PostMapping("/chapters")
fun createChapter(

View File

@@ -92,6 +92,21 @@ class CourseContentIntegrationTest {
.build()
}
@Test
fun `admin article list supports offset and limit`() {
repeat(3) { seedMaterial(courseId = null, published = true) }
val all = contentService.listAll()
assertThat(all.size).isGreaterThanOrEqualTo(3)
val first = contentService.listAll(offset = 0, limit = 2)
val second = contentService.listAll(offset = 2, limit = 1)
assertThat(first).hasSize(2)
assertThat(second).hasSize(1)
assertThat(first.map { it.id }).doesNotContainAnyElementsOf(second.map { it.id })
assertThat(contentService.listAll(offset = 0, limit = 0)).hasSize(all.size)
}
@Test
fun `anonymous tree contains only global chapters with ALL audience`() {
val global = seedChapter(title = "Глобальная", courseId = null, audience = "ALL")

View File

@@ -17,13 +17,15 @@ class GroupEntity(
var id: UUID = Ids.v7(),
@Column(nullable = false, length = 100)
var name: String = "",
@Column(length = 50)
@Column(length = 50, nullable = false)
var stream: String? = null,
@Column(nullable = false)
var course: Short? = null,
@Column(nullable = false)
var number: Short? = null,
@Column(length = 2)
@Column(length = 2, nullable = false)
var degree: String? = null,
@Column(name = "enrollment_year")
@Column(name = "enrollment_year", nullable = false)
var enrollmentYear: Short? = null,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)

View File

@@ -21,6 +21,8 @@ data class AccountInfoDto(
val id: String,
val login: String,
val role: String,
/** ФИО студента; для не-студенческих аккаунтов пусто. */
val fullName: String = "",
)
/** Группа с текущим составом — без учётных данных. */

View File

@@ -76,15 +76,25 @@ class InternalService(
)
}
/** Логины авторов комментариев: неизвестные id пропускаются, дубликаты схлопываются. */
/** Подписи авторов комментариев: неизвестные id пропускаются, дубликаты схлопываются. */
@Transactional(readOnly = true)
fun lookupAccounts(ids: List<String>): List<AccountInfoDto> {
val requested = ids.map(::parseId).distinct()
if (requested.isEmpty()) return emptyList()
val found = accounts.findAllById(requested).associateBy { it.id }
val names =
students
.findAllById(requested)
.mapNotNull { student -> student.accountId?.let { it to student.fullName } }
.toMap()
return requested.mapNotNull { id ->
found[id]?.let { account ->
AccountInfoDto(id = account.id.toString(), login = account.login, role = account.role.name)
AccountInfoDto(
id = account.id.toString(),
login = account.login,
role = account.role.name,
fullName = names[account.id].orEmpty(),
)
}
}
}

View File

@@ -1,5 +1,6 @@
package ru.ysu.lms.identity.repo
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.identity.domain.GroupEntity
import java.util.UUID
@@ -20,4 +21,6 @@ interface GroupRepository : JpaRepository<GroupEntity, UUID> {
fun findAllByOrderByNameAsc(): List<GroupEntity>
fun findAllByOrderByEnrollmentYearDescNameAsc(): List<GroupEntity>
fun findAllByOrderByEnrollmentYearDescNameAsc(pageable: Pageable): List<GroupEntity>
}

View File

@@ -6,4 +6,9 @@ import java.util.UUID
interface MembershipHistoryRepository : JpaRepository<MembershipHistory, UUID> {
fun findByStudentIdOrderByAtDesc(studentId: UUID): List<MembershipHistory>
fun findTop200ByToGroupIdOrFromGroupIdOrderByAtDesc(
toGroupId: UUID,
fromGroupId: UUID,
): List<MembershipHistory>
}

View File

@@ -31,7 +31,10 @@ class GroupController(
private val auditService: AuditService,
) {
@GetMapping
fun list(): List<GroupSummary> = groupService.list()
fun list(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "0") limit: Int,
): List<GroupSummary> = groupService.list(page, limit)
@PostMapping
fun create(
@@ -148,6 +151,29 @@ class GroupController(
return detail
}
/** Возврат группы из архива. */
@PostMapping("/{id}/restore")
fun restore(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): GroupDetail {
val detail = groupService.restoreGroup(id)
auditService.record(
jwt.toAuditRequest(
action = "GROUP_RESTORE",
entityType = "GROUP",
entityId = id.toString(),
details = "name=${detail.name}",
),
)
return detail
}
@GetMapping("/{id}/history")
fun history(
@PathVariable id: UUID,
): List<MembershipHistoryRow> = groupService.history(id)
@GetMapping("/{id}/credentials")
fun credentials(
@PathVariable id: UUID,

View File

@@ -1,5 +1,6 @@
package ru.ysu.lms.identity.roster
import org.springframework.data.domain.PageRequest
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -34,8 +35,22 @@ class GroupService(
private val passwordEncoder: PasswordEncoder,
private val cohortResolver: GroupCohortResolver,
) {
/** Список групп; [limit] = 0 — все записи (совместимость с существующим API). */
@Transactional(readOnly = true)
fun list(): List<GroupSummary> = groups.findAllByOrderByEnrollmentYearDescNameAsc().map { summary(it) }
fun list(
page: Int = 0,
limit: Int = 0,
): List<GroupSummary> {
val rows =
if (limit <= 0) {
groups.findAllByOrderByEnrollmentYearDescNameAsc()
} else {
groups.findAllByOrderByEnrollmentYearDescNameAsc(
PageRequest.of(page.coerceAtLeast(0), limit.coerceIn(1, MAX_GROUP_PAGE)),
)
}
return rows.map { summary(it) }
}
@Transactional
fun createStudent(
@@ -262,6 +277,43 @@ class GroupService(
return detail(groupId)
}
/** Последние события членства, связанные с группой (вход или выход). */
@Transactional(readOnly = true)
fun history(groupId: UUID): List<MembershipHistoryRow> {
groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val events = history.findTop200ByToGroupIdOrFromGroupIdOrderByAtDesc(groupId, groupId)
val studentIds = events.map { it.studentId }.distinct()
val studentsById = students.findAllById(studentIds).associateBy { it.accountId }
val groupIds = events.flatMap { listOfNotNull(it.fromGroupId, it.toGroupId) }.distinct()
val groupsById = groups.findAllById(groupIds).associateBy { it.id }
return events.map { event ->
MembershipHistoryRow(
id = event.id.toString(),
studentId = event.studentId.toString(),
studentName = studentsById[event.studentId]?.fullName.orEmpty(),
fromGroupId = event.fromGroupId?.toString(),
fromGroupName = event.fromGroupId?.let { groupsById[it]?.name },
toGroupId = event.toGroupId?.toString(),
toGroupName = event.toGroupId?.let { groupsById[it]?.name },
action = event.action.name,
reason = event.reason,
at = event.at.toString(),
)
}
}
/** Возвращает архивную группу в работу; состав и история не меняются. */
@Transactional
fun restoreGroup(groupId: UUID): GroupDetail {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
if (group.status == GroupStatus.ACTIVE) {
throw BadRequestException("Группа ${group.name} уже активна")
}
group.status = GroupStatus.ACTIVE
groups.save(group)
return detail(groupId)
}
/**
* Удаляет студента из identity: зачисление, профиль, аккаунт.
* [confirm] должен совпадать с логином аккаунта или ФИО студента.
@@ -350,6 +402,10 @@ class GroupService(
}
}
private companion object {
const val MAX_GROUP_PAGE = 500
}
private fun activeGroup(groupId: UUID): GroupEntity {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
if (group.status != GroupStatus.ACTIVE) {

View File

@@ -85,6 +85,20 @@ data class ResetPasswordResponse(
val password: String,
)
/** Событие истории членства для страницы группы. */
data class MembershipHistoryRow(
val id: String,
val studentId: String,
val studentName: String,
val fromGroupId: String?,
val fromGroupName: String?,
val toGroupId: String?,
val toGroupName: String?,
val action: String,
val reason: String,
val at: String,
)
data class StudentCandidate(
val id: String,
val fullName: String,

View File

@@ -0,0 +1,43 @@
-- Финальные ограничения когорты. Легаси-группы, которые не удалось разобрать
-- из имени в V5, должны быть исправлены вручную до применения этой миграции:
-- SELECT id, name, stream, degree, course, number, enrollment_year
-- FROM identity.groups
-- WHERE stream IS NULL OR degree IS NULL OR course IS NULL
-- OR number IS NULL OR enrollment_year IS NULL;
-- После UPDATE полей миграцию можно применить повторно.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM groups
WHERE stream IS NULL
OR degree IS NULL
OR course IS NULL
OR number IS NULL
OR enrollment_year IS NULL
) THEN
RAISE EXCEPTION
'Есть группы с незаполненной когортой; исправьте их перед V14 (см. комментарий в миграции)';
END IF;
END $$;
ALTER TABLE groups
ALTER COLUMN stream SET NOT NULL,
ALTER COLUMN degree SET NOT NULL,
ALTER COLUMN course SET NOT NULL,
ALTER COLUMN number SET NOT NULL,
ALTER COLUMN enrollment_year SET NOT NULL;
ALTER TABLE groups DROP CONSTRAINT IF EXISTS groups_degree_check;
ALTER TABLE groups ADD CONSTRAINT groups_degree_check CHECK (degree IN ('БО', 'МО'));
ALTER TABLE groups DROP CONSTRAINT IF EXISTS groups_course_range;
ALTER TABLE groups ADD CONSTRAINT groups_course_range CHECK (
(degree = 'БО' AND course BETWEEN 1 AND 4)
OR (degree = 'МО' AND course BETWEEN 1 AND 2)
);
-- Полная уникальность когорты: все поля уже NOT NULL.
DROP INDEX IF EXISTS groups_cohort_unique;
CREATE UNIQUE INDEX groups_cohort_unique
ON groups (stream, degree, number, course, enrollment_year);

View File

@@ -8,7 +8,11 @@ import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.mock.web.MockMultipartFile
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
import org.springframework.test.context.DynamicPropertyRegistry
@@ -25,10 +29,13 @@ import org.springframework.web.context.WebApplicationContext
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import ru.ysu.lms.identity.domain.Account
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.GroupController
import ru.ysu.lms.identity.roster.GroupService
import ru.ysu.lms.security.LmsRole
import java.nio.charset.StandardCharsets
import java.util.UUID
@@ -63,6 +70,8 @@ class AuditIntegrationTest {
@Autowired lateinit var groupService: GroupService
@Autowired lateinit var groupController: GroupController
@Autowired lateinit var context: WebApplicationContext
private val objectMapper = ObjectMapper()
@@ -378,6 +387,80 @@ class AuditIntegrationTest {
assertTrue(auditEvents.findById(deleteEvent.id).isPresent)
}
@Test
@Suppress("LongMethod")
fun `roster operations record group and student audit events`() {
val actor =
accounts
.save(
Account(
login = "audit.admin.${UUID.randomUUID().toString().take(8)}",
passwordHash = "x",
role = LmsRole.ADMIN,
mustChangePassword = false,
),
).id
val jwt =
Jwt
.withTokenValue("admin-token")
.header("alg", "none")
.subject(actor.toString())
.claim("login", "admin.audit")
.build()
val stream = "A" + UUID.randomUUID().toString().take(8)
SecurityContextHolder.getContext().authentication =
JwtAuthenticationToken(jwt, listOf(SimpleGrantedAuthority("ROLE_ADMIN")))
try {
val group =
groupController.create(
CreateGroupRequest(stream = stream, course = 1, number = 1, degree = "БО", enrollmentYear = 2025),
jwt,
)
val credential =
groupController.createStudent(
UUID.fromString(group.id),
CreateStudentRequest(studentNo = "9901", lastName = "Аудитов", firstName = "Иван"),
jwt,
)
val target =
groupController.create(
CreateGroupRequest(stream = stream, course = 1, number = 2, degree = "БО", enrollmentYear = 2025),
jwt,
)
groupController.enroll(UUID.fromString(target.id), UUID.fromString(credential.id), jwt)
groupController.archive(UUID.fromString(target.id), jwt)
groupController.restore(UUID.fromString(target.id), jwt)
val csvBytes =
(
"поток;курс;номер;степень;год набора;ФИО;№;база\n" +
"$stream;1;3;БО;2025;Импортов Пётр;9902;бюджет"
).toByteArray(StandardCharsets.UTF_8)
groupController.import(MockMultipartFile("file", "group.csv", "text/csv", csvBytes), false, jwt)
val events = auditEvents.findTop200ByOrderByOccurredAtDesc()
assertTrue(
events
.map { it.action }
.toSet()
.containsAll(
setOf(
"GROUP_CREATE",
"STUDENT_CREATE",
"GROUP_ENROLL",
"GROUP_IMPORT",
"GROUP_ARCHIVE",
"GROUP_RESTORE",
),
),
)
val created = events.first { it.action == "GROUP_CREATE" && it.details.contains(group.name) }
assertEquals(actor, created.actorAccountId)
assertEquals("admin.audit", created.actorLogin)
} finally {
SecurityContextHolder.clearContext()
}
}
private data class AuditPage(
val details: List<String>,
val cursor: String?,

View File

@@ -130,6 +130,24 @@ class InternalControllerIntegrationTest {
}
}
@Test
fun `lookup includes student full name and leaves staff name empty`() {
val student = student(login = "a.ivanov", studentNo = "123456")
val staff = account("t.teacher")
mockMvc
.post("/api/v1/internal/accounts/lookup") {
header("X-Internal-Token", TOKEN)
contentType = MediaType.APPLICATION_JSON
content = """{"ids":["${student.accountId}","${staff.id}"]}"""
}.andExpect {
status { isOk() }
jsonPath("$.length()") { value(2) }
jsonPath("$[0].fullName") { value("Иванов Иван Иванович") }
jsonPath("$[1].fullName") { value("") }
}
}
@Test
fun `lookup ignores missing accounts and duplicates`() {
val existing = account("a.ivanov")
@@ -275,8 +293,29 @@ class InternalControllerIntegrationTest {
@Test
fun `valid token returns groups sorted by name with status`() {
val graduated = groups.save(GroupEntity(name = "ПИ-22БО", enrollmentYear = 2025, status = GroupStatus.GRADUATED))
val active = groups.save(GroupEntity(name = "ПИ-21БО", enrollmentYear = 2025))
val graduated =
groups.save(
GroupEntity(
name = "ПИ-22БО",
stream = "ПИ",
course = 2,
number = 2,
degree = "БО",
enrollmentYear = 2025,
status = GroupStatus.GRADUATED,
),
)
val active =
groups.save(
GroupEntity(
name = "ПИ-21БО",
stream = "ПИ",
course = 2,
number = 1,
degree = "БО",
enrollmentYear = 2025,
),
)
mockMvc
.get("/api/v1/internal/groups") {
@@ -338,7 +377,19 @@ class InternalControllerIntegrationTest {
),
)
private fun group(name: String): GroupEntity = groups.save(GroupEntity(name = name, enrollmentYear = 2025))
private fun group(name: String): GroupEntity {
val match = Regex("""-(\d)(\d+)(БО|МО)$""").find(name)
return groups.save(
GroupEntity(
name = name,
stream = "ПИ",
course = match?.groupValues?.get(1)?.toShortOrNull() ?: 1,
number = match?.groupValues?.get(2)?.toShortOrNull() ?: 1,
degree = match?.groupValues?.get(3) ?: "БО",
enrollmentYear = 2025,
),
)
}
private fun enroll(
group: GroupEntity,

View File

@@ -1,5 +1,6 @@
package ru.ysu.lms.identity.roster
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
@@ -13,6 +14,7 @@ import org.springframework.transaction.annotation.Transactional
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.AuthService
@@ -26,6 +28,7 @@ import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.MembershipHistoryRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.LmsRole
import java.io.ByteArrayOutputStream
import java.util.UUID
@SpringBootTest
@@ -89,6 +92,40 @@ class GroupServiceIntegrationTest {
assertTrue(error is ConflictException)
}
@Test
fun `creates two distinct cohorts in the same stream`() {
val first = groupService.create(CreateGroupRequest(stream = "ТЕСТ", course = 1, number = 1, degree = "БО", enrollmentYear = 2025))
val second = groupService.create(CreateGroupRequest(stream = "ТЕСТ", course = 1, number = 2, degree = "БО", enrollmentYear = 2025))
assertEquals("ТЕСТ-11БО", first.name)
assertEquals("ТЕСТ-12БО", second.name)
}
@Test
fun `lists groups page by page and returns all without limit`() {
val stream = "P" + UUID.randomUUID().toString().take(8)
repeat(3) { index ->
groupService.create(
CreateGroupRequest(
stream = stream,
course = 1,
number = (index + 1).toShort(),
degree = "БО",
enrollmentYear = 2025,
),
)
}
val first = groupService.list(page = 0, limit = 2).filter { it.name.startsWith(stream) }
val second = groupService.list(page = 1, limit = 2).filter { it.name.startsWith(stream) }
val all = groupService.list().filter { it.name.startsWith(stream) }
assertEquals(2, first.size)
assertEquals(1, second.size)
assertEquals(3, all.size)
assertTrue(first.intersect(second.toSet()).isEmpty())
}
@Test
fun `imports students and records membership history`() {
val result = importService.import("group.csv", csv(validRows()), null)
@@ -106,6 +143,11 @@ class GroupServiceIntegrationTest {
assertEquals(2025.toShort(), student.enrollmentYear)
val events = history.findByStudentIdOrderByAtDesc(studentId)
assertEquals(MembershipAction.IMPORT, events.first().action)
val groupHistory = groupService.history(groupId)
val imported = groupHistory.first { it.studentId == studentId.toString() }
assertEquals("IMPORT", imported.action)
assertEquals("Иванов Иван", imported.studentName)
assertEquals("ПИ-21БО", imported.toGroupName)
}
@Test
@@ -272,6 +314,64 @@ class GroupServiceIntegrationTest {
assertNull(students.findByStudentNo("3001"))
}
@Test
fun `restores archived group and rejects repeated restore`() {
val created = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 7, degree = "БО", enrollmentYear = 2026))
val id = UUID.fromString(created.id)
assertEquals("ARCHIVED", groupService.archiveGroup(id).status)
assertEquals("ACTIVE", groupService.restoreGroup(id).status)
val error = runCatching { groupService.restoreGroup(id) }.exceptionOrNull()
assertTrue(error is BadRequestException)
}
@Test
fun `xlsx import reports occupied login before writing`() {
val studentNo = "X" + UUID.randomUUID().toString().take(8)
accounts.save(
Account(
login = studentNo,
passwordHash = "x",
role = LmsRole.STUDENT,
mustChangePassword = true,
),
)
val result = importService.import("group.xlsx", xlsx("ПИ;1;4;БО;2026;Иванов Иван;$studentNo;бюджет"), null)
assertFalse(result.applied)
assertEquals(1, result.errors.size)
assertEquals(2, result.errors.single().row)
assertTrue(
result.errors
.single()
.message
.contains("Логин $studentNo"),
)
assertNull(students.findByStudentNo(studentNo))
}
@Test
fun `xlsx import reports inactive target group before writing`() {
val group = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 5, degree = "БО", enrollmentYear = 2026))
groupService.archiveGroup(UUID.fromString(group.id))
val studentNo = "Y" + UUID.randomUUID().toString().take(8)
val result = importService.import("group.xlsx", xlsx("ПИ;1;5;БО;2026;Петров Пётр;$studentNo;бюджет"), null)
assertFalse(result.applied)
assertEquals(1, result.errors.size)
assertEquals(2, result.errors.single().row)
assertTrue(
result.errors
.single()
.message
.contains("не активна"),
)
assertNull(students.findByStudentNo(studentNo))
}
@Test
fun `import reports inactive target group and writes nothing`() {
val group = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 8, degree = "БО", enrollmentYear = 2026))
@@ -324,4 +424,22 @@ class GroupServiceIntegrationTest {
""".trimIndent()
private fun csv(rows: String): ByteArray = ("поток;курс;номер;степень;год набора;ФИО;№;база\n" + rows).toByteArray()
private fun xlsx(row: String): ByteArray {
val headers = listOf("поток", "курс", "номер", "степень", "год набора", "ФИО", "№", "база")
val values = row.split(";")
return XSSFWorkbook().use { workbook ->
val sheet = workbook.createSheet("group")
sheet.createRow(0).apply {
headers.forEachIndexed { index, header -> createCell(index).setCellValue(header) }
}
sheet.createRow(1).apply {
values.forEachIndexed { index, value -> createCell(index).setCellValue(value) }
}
ByteArrayOutputStream().use { out ->
workbook.write(out)
out.toByteArray()
}
}
}
}

View File

@@ -122,6 +122,7 @@ data class AccountInfo(
val id: String = "",
val login: String = "",
val role: String = "",
val fullName: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)

View File

@@ -20,8 +20,9 @@ class GroupLabDataService(
groupId: UUID,
labIds: List<UUID>,
token: String,
): GroupLabDataResponse =
GroupLabDataResponse(
): GroupLabDataResponse {
val latestSubmissions = submissions.latestPerStudentByLab(labIds)
return GroupLabDataResponse(
groupId = groupId.toString(),
labs =
labIds.map { labId ->
@@ -37,10 +38,11 @@ class GroupLabDataService(
manual = sll.manual,
)
},
submissions = submissions.latestPerStudent(labId),
submissions = latestSubmissions[labId].orEmpty(),
defenses = defenses.listForLab(labId),
publication = publications.state(groupId, labId),
)
},
)
}
}

View File

@@ -159,6 +159,19 @@ class SubmissionService(
return perStudent.values.map { toDto(it, filesOf(it)) }
}
/** Последняя сдача каждого студента по нескольким лабораторным — одним запросом. */
@Transactional(readOnly = true)
fun latestPerStudentByLab(labIds: Collection<UUID>): Map<UUID, List<SubmissionDto>> {
if (labIds.isEmpty()) return emptyMap()
val perLabAndStudent = mutableMapOf<Pair<UUID, UUID>, Submission>()
for (s in submissions.findByLabIdInOrderByCreatedAtDesc(labIds)) {
perLabAndStudent.putIfAbsent(s.labId to s.studentId, s)
}
return perLabAndStudent.values
.groupBy { it.labId }
.mapValues { (_, rows) -> rows.map { toDto(it, filesOf(it)) } }
}
/** Файлы с "исходниковым" расширением, содержимое которых — XML-файл проекта (VS/IDE). */
private val sourceExtensions = setOf("c", "h", "cpp", "hpp", "cc", "cxx", "txt")

View File

@@ -9,6 +9,7 @@ import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.progress.audit.AuditClient
import java.net.URLEncoder
@@ -26,6 +27,7 @@ class ProfileController(
fun profile(
@PathVariable courseId: UUID,
@PathVariable studentId: UUID,
@RequestParam(defaultValue = "50") submissionLimit: Int,
@AuthenticationPrincipal jwt: Jwt,
): StudentCourseProfile {
val profile =
@@ -35,6 +37,7 @@ class ProfileController(
callerId = UUID.fromString(jwt.subject),
callerRole = role(jwt),
token = jwt.tokenValue,
submissionLimit = submissionLimit,
)
// Свой профиль студент открывает часто; аудит нужен для Staff-просмотров,
// чтобы не нагружать identity на горячем пути и не засорять журнал.

View File

@@ -29,24 +29,28 @@ data class ProfileSummary(
)
data class ProfileVariant(
val id: String,
val number: String,
val title: String,
)
data class ProfileLevel(
val id: String,
val code: String,
val title: String,
)
/** Подпись автора оценки/комментария: ФИО, при отсутствии — логин. */
data class ProfileAuthor(
val id: String,
val login: String,
val name: String,
)
data class ProfileGrade(
val score: String,
val published: Boolean,
val generalComment: String,
val author: ProfileAuthor?,
val updatedBy: ProfileAuthor?,
)
data class ProfileComment(
@@ -86,6 +90,8 @@ data class ProfileLab(
val grade: ProfileGrade?,
val comments: List<ProfileComment>,
val defense: ProfileDefense?,
/** Всего попыток; [submissions] может быть ограничен для больших курсов. */
val submissionsTotal: Int = 0,
val submissions: List<ProfileSubmission>,
)

View File

@@ -57,8 +57,10 @@ class StudentProfileService(
callerId: UUID,
callerRole: String,
token: String,
submissionLimit: Int = DEFAULT_SUBMISSION_LIMIT,
): StudentCourseProfile {
val role = normalizeRole(callerRole)
val effectiveLimit = submissionLimit.coerceIn(1, MAX_SUBMISSION_LIMIT)
val course = academic.getCourse(courseId.toString(), token)
val context = identity.getStudentContext(studentId.toString())
requireProfileAccess(course, context, studentId, callerId, role)
@@ -85,7 +87,7 @@ class StudentProfileService(
allComments[labId].orEmpty().forEach { it.authorAccountId?.let(authorIds::add) }
}
}
val logins = lookupLogins(authorIds)
val authors = lookupAuthors(authorIds)
val profileLabs =
labs.map { (labId, lab) ->
@@ -102,7 +104,8 @@ class StudentProfileService(
levelId = levelsByLab[labId],
published = labId in published,
showUnpublished = showUnpublished,
logins = logins,
authors = authors,
submissionLimit = effectiveLimit,
)
}
@@ -266,12 +269,15 @@ class StudentProfileService(
return all
}
private fun lookupLogins(authorIds: Set<UUID>): Map<String, String> {
private fun lookupAuthors(authorIds: Set<UUID>): Map<String, ProfileAuthor> {
if (authorIds.isEmpty()) return emptyMap()
val accounts = runCatching { identity.lookupAccounts(authorIds.map(UUID::toString)) }.getOrDefault(emptyList())
return accounts
.filter { it.id.isNotBlank() }
.associate { it.id to it.login.ifBlank { UNKNOWN_AUTHOR } }
.associate { account ->
val name = account.fullName.ifBlank { account.login }.ifBlank { UNKNOWN_AUTHOR }
account.id to ProfileAuthor(account.id, name)
}
}
@Suppress("LongParameterList")
@@ -288,12 +294,14 @@ class StudentProfileService(
levelId: UUID?,
published: Boolean,
showUnpublished: Boolean,
logins: Map<String, String>,
authors: Map<String, ProfileAuthor>,
submissionLimit: Int,
): ProfileLab {
val canSeeGrade = showUnpublished || published
val sortedSubmissions = submissions.sortedByDescending { it.seq }
val allSubmissions = submissions.sortedByDescending { it.seq }
val sortedSubmissions = allSubmissions.take(submissionLimit)
val lastSubmissionAt =
sortedSubmissions.maxWithOrNull(compareBy<Submission> { it.createdAt }.thenBy { it.seq })?.createdAt?.toString()
allSubmissions.maxWithOrNull(compareBy<Submission> { it.createdAt }.thenBy { it.seq })?.createdAt?.toString()
val gradeDto =
grade
?.takeIf { canSeeGrade }
@@ -302,7 +310,7 @@ class StudentProfileService(
score = it.score.toPlainString(),
published = published,
generalComment = it.generalComment,
author = authorOf(it.updatedByAccountId, logins),
updatedBy = authorOf(it.updatedByAccountId, authors),
)
}
val commentDtos =
@@ -314,7 +322,7 @@ class StudentProfileService(
id = it.id.toString(),
text = it.text,
tone = it.tone,
author = authorOf(it.authorAccountId, logins),
author = authorOf(it.authorAccountId, authors),
createdAt = it.createdAt.toString(),
)
}
@@ -328,11 +336,12 @@ class StudentProfileService(
deadlineAt = lab.deadlineAt,
variant = variantOf(lab, variantId),
level = levelOf(lab, levelId),
attemptsCount = sortedSubmissions.size,
attemptsCount = allSubmissions.size,
lastSubmissionAt = lastSubmissionAt,
grade = gradeDto,
comments = commentDtos,
defense = defense?.let { toDefense(it) },
submissionsTotal = allSubmissions.size,
submissions =
sortedSubmissions.map { s ->
ProfileSubmission(
@@ -357,7 +366,7 @@ class StudentProfileService(
.flatMap { it.variants.asSequence() }
.firstOrNull { it.id == variantId.toString() }
?: return null
return ProfileVariant(variant.number, variant.title)
return ProfileVariant(variant.id.ifBlank { variantId.toString() }, variant.number, variant.title)
}
private fun levelOf(
@@ -366,7 +375,7 @@ class StudentProfileService(
): ProfileLevel? {
if (levelId == null) return null
val level = lab.levels.firstOrNull { it.id == levelId.toString() } ?: return null
return ProfileLevel(level.title)
return ProfileLevel(level.id.ifBlank { levelId.toString() }, level.code, level.title)
}
private fun toDefense(session: DefenseSession): ProfileDefense {
@@ -422,12 +431,8 @@ class StudentProfileService(
private fun authorOf(
authorId: UUID?,
logins: Map<String, String>,
): ProfileAuthor? =
authorId?.let {
val key = it.toString()
ProfileAuthor(key, logins[key] ?: UNKNOWN_AUTHOR)
}
authors: Map<String, ProfileAuthor>,
): ProfileAuthor? = authorId?.let { authors[it.toString()] ?: ProfileAuthor(it.toString(), UNKNOWN_AUTHOR) }
private fun normalizeRole(role: String): String = role.removePrefix("ROLE_").uppercase()
@@ -436,5 +441,7 @@ class StudentProfileService(
const val ROLE_TEACHER = "TEACHER"
const val ROLE_STUDENT = "STUDENT"
const val UNKNOWN_AUTHOR = "—"
const val DEFAULT_SUBMISSION_LIMIT = 50
const val MAX_SUBMISSION_LIMIT = 500
}
}

View File

@@ -19,6 +19,8 @@ interface SubmissionRepository : JpaRepository<Submission, UUID> {
fun findByLabIdOrderByCreatedAtDesc(labId: UUID): List<Submission>
fun findByLabIdInOrderByCreatedAtDesc(labIds: Collection<UUID>): List<Submission>
fun findFirstByStudentIdAndLabIdOrderBySeqDesc(
studentId: UUID,
labId: UUID,

View File

@@ -1,7 +1,9 @@
package ru.ysu.lms.progress
import jakarta.persistence.EntityManagerFactory
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.hibernate.SessionFactory
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyCollection
import org.mockito.Mockito.`when`
@@ -31,7 +33,12 @@ import java.util.UUID
* Проверяются доступ, публикация оценок, все попытки сдач и скачивание.
*/
@Testcontainers
@SpringBootTest(properties = ["lms.submissions.dir=build/test-profile-submissions"])
@SpringBootTest(
properties = [
"lms.submissions.dir=build/test-profile-submissions",
"spring.jpa.properties.hibernate.generate_statistics=true",
],
)
class StudentProfileIntegrationTest {
companion object {
@Container
@@ -65,6 +72,9 @@ class StudentProfileIntegrationTest {
@Autowired
private lateinit var storage: SubmissionStorage
@Autowired
private lateinit var entityManagerFactory: EntityManagerFactory
@MockitoBean
private lateinit var academic: AcademicClient
@@ -72,6 +82,25 @@ class StudentProfileIntegrationTest {
private lateinit var identity: IdentityClient
@Test
fun `profile uses one query per data kind instead of per lab`() {
val courseId = UUID.randomUUID()
val groupId = UUID.randomUUID()
val studentId = UUID.randomUUID()
val labs = (1..10).map { labInfo(UUID.randomUUID(), it, UUID.randomUUID(), UUID.randomUUID()) }
stubCourse(courseId, groupId, TEACHER_TOKEN, labs)
stubStudent(studentId, groupId)
val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics
statistics.clear()
val profile = profiles.profile(courseId, studentId, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN)
assertThat(profile.labs).hasSize(10)
assertThat(statistics.prepareStatementCount).isLessThanOrEqualTo(15)
}
@Test
@Suppress("LongMethod")
fun `teacher sees unpublished grade, authors and all submission attempts`() {
val courseId = UUID.randomUUID()
val groupId = UUID.randomUUID()
@@ -111,9 +140,13 @@ class StudentProfileIntegrationTest {
assertThat(profile.summary.averageScore).isEqualTo(4.5)
assertThat(profile.labs).hasSize(1)
val lab = profile.labs.single()
assertThat(lab.variant?.id).isEqualTo(variantId.toString())
assertThat(lab.variant?.number).isEqualTo("7")
assertThat(lab.level?.id).isEqualTo(levelId.toString())
assertThat(lab.level?.code).isEqualTo("base")
assertThat(lab.level?.title).isEqualTo("Базовый")
assertThat(lab.attemptsCount).isEqualTo(2)
assertThat(lab.submissionsTotal).isEqualTo(2)
assertThat(lab.submissions.map { it.seq }).containsExactly(2, 1)
assertThat(lab.submissions.first().files).containsExactlyInAnyOrder("main.c", "README.md")
assertThat(lab.submissions.first().downloadUrl)
@@ -121,10 +154,10 @@ class StudentProfileIntegrationTest {
val grade = checkNotNull(lab.grade)
assertThat(grade.score).isEqualTo("4.5")
assertThat(grade.published).isFalse()
assertThat(grade.author?.login).isEqualTo("teacher_login")
assertThat(grade.updatedBy?.name).isEqualTo("teacher_login")
assertThat(lab.comments).hasSize(1)
val commentAuthor = lab.comments.single().author
assertThat(commentAuthor?.login).isEqualTo("teacher_login")
assertThat(commentAuthor?.name).isEqualTo("teacher_login")
val download =
profiles.downloadSubmission(courseId, second.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN)
@@ -236,6 +269,27 @@ class StudentProfileIntegrationTest {
assertThat(download.filename).endsWith(".zip")
}
@Test
fun `profile limits submission attempts but reports total`() {
val courseId = UUID.randomUUID()
val groupId = UUID.randomUUID()
val studentId = UUID.randomUUID()
val labId = UUID.randomUUID()
stubCourse(courseId, groupId, TEACHER_TOKEN, listOf(labInfo(labId, 1, UUID.randomUUID(), UUID.randomUUID())))
stubStudent(studentId, groupId)
saveSubmission(studentId, labId, 1, "2026-10-01T10:00:00Z", "main.c")
saveSubmission(studentId, labId, 2, "2026-10-02T10:00:00Z", "main.c")
saveSubmission(studentId, labId, 3, "2026-10-03T10:00:00Z", "main.c")
val profile = profiles.profile(courseId, studentId, UUID.randomUUID(), "ADMIN", TEACHER_TOKEN, submissionLimit = 1)
val lab = profile.labs.single()
assertThat(lab.submissionsTotal).isEqualTo(3)
assertThat(lab.attemptsCount).isEqualTo(3)
assertThat(lab.submissions.map { it.seq }).containsExactly(3)
}
private fun stubCourse(
courseId: UUID,
groupId: UUID,

View File

@@ -0,0 +1,63 @@
package ru.ysu.lms.progress
import org.assertj.core.api.Assertions.assertThat
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.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.postgresql.PostgreSQLContainer
import ru.ysu.lms.progress.domain.Submission
import ru.ysu.lms.progress.repo.SubmissionRepository
import java.time.Instant
import java.util.UUID
/** Пакетная выборка последних сдач по нескольким лабораторным для ведомости. */
@Testcontainers
@SpringBootTest(properties = ["lms.submissions.dir=build/test-batch-submissions"])
class SubmissionBatchIntegrationTest {
companion object {
@Container
@ServiceConnection
@JvmStatic
val postgres = PostgreSQLContainer("postgres:18")
}
@Autowired
private lateinit var service: SubmissionService
@Autowired
private lateinit var submissions: SubmissionRepository
@Test
fun `latest submissions are grouped by lab in one call`() {
val student = UUID.randomUUID()
val otherStudent = UUID.randomUUID()
val firstLab = UUID.randomUUID()
val secondLab = UUID.randomUUID()
submissions.save(submission(student, firstLab, seq = 1, at = "2026-10-01T10:00:00Z"))
submissions.save(submission(student, firstLab, seq = 2, at = "2026-10-02T10:00:00Z"))
submissions.save(submission(otherStudent, firstLab, seq = 1, at = "2026-10-03T10:00:00Z"))
submissions.save(submission(student, secondLab, seq = 1, at = "2026-10-04T10:00:00Z"))
val result = service.latestPerStudentByLab(listOf(firstLab, secondLab))
assertThat(result.keys).containsExactlyInAnyOrder(firstLab, secondLab)
assertThat(result.getValue(firstLab).map { it.seq }).containsExactlyInAnyOrder(2, 1)
assertThat(result.getValue(secondLab).map { it.seq }).containsExactly(1)
}
@Test
fun `empty lab list returns empty map`() {
assertThat(service.latestPerStudentByLab(emptyList())).isEmpty()
}
private fun submission(
studentId: UUID,
labId: UUID,
seq: Int,
at: String,
): Submission = Submission(studentId = studentId, labId = labId, seq = seq, createdAt = Instant.parse(at))
}