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

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