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:
@@ -122,6 +122,7 @@ data class AccountInfo(
|
||||
val id: String = "",
|
||||
val login: String = "",
|
||||
val role: String = "",
|
||||
val fullName: String = "",
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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 на горячем пути и не засорять журнал.
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user