feat(progress): профиль студента курса и скачивание попыток

This commit is contained in:
2026-10-27 17:45:07 +03:00
parent a970fe62c4
commit fa097518f8
20 changed files with 1117 additions and 5 deletions

View File

@@ -13,6 +13,10 @@ spring:
uri: ${LMS_IDENTITY_URI:http://localhost:8081}
predicates:
- Path=/api/v1/auth/**,/api/v1/accounts/**,/api/v1/groups/**,/api/v1/students/**,/api/v1/teachers/**
- id: progress-profile
uri: ${LMS_PROGRESS_URI:http://localhost:8083}
predicates:
- Path=/api/v1/courses/*/students/*/profile,/api/v1/courses/*/submissions/*/download
- id: academic
uri: ${LMS_ACADEMIC_URI:http://localhost:8082}
predicates:

View File

@@ -5,6 +5,8 @@ import org.springframework.core.ParameterizedTypeReference
import org.springframework.http.client.JdkClientHttpRequestFactory
import org.springframework.stereotype.Service
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientResponseException
import ru.ysu.lms.common.ForbiddenException
import ru.ysu.lms.common.NotFoundException
import java.net.http.HttpClient
import java.time.Duration
@@ -38,11 +40,71 @@ class AcademicClient(
.retrieve()
.body(LabInfo::class.java)
?: throw NotFoundException("Лабораторная не найдена")
/** Курс с группами; academic-service сам проверяет доступ по пользовательскому токену. */
fun getCourse(
courseId: String,
token: String,
): CourseInfo =
try {
client
.get()
.uri("/api/v1/courses/{id}", courseId)
.header("Authorization", "Bearer $token")
.retrieve()
.body(CourseInfo::class.java)
?: throw NotFoundException("Курс не найден")
} catch (ex: RestClientResponseException) {
throw courseError(ex)
}
/**
* Лабораторные курса вместе с уровнями и вариантами: список отдаёт только сводки,
* поэтому детали каждой лабораторной подтягиваются отдельно. Ошибка детали не
* должна ронять весь профиль — остаётся сводка без уровней.
*/
fun getCourseLabs(
courseId: String,
token: String,
): List<LabInfo> {
val type = object : ParameterizedTypeReference<List<LabInfo>>() {}
val summaries =
try {
client
.get()
.uri("/api/v1/labs?courseId={courseId}", courseId)
.header("Authorization", "Bearer $token")
.retrieve()
.body(type) ?: emptyList()
} catch (ex: RestClientResponseException) {
throw courseError(ex)
}
return summaries.map { summary ->
if (summary.id.isBlank()) {
summary
} else {
val detail = runCatching { getLab(summary.id, token) }.getOrNull() ?: summary
detail.copy(
courseId = detail.courseId ?: summary.courseId ?: courseId,
courseNo = detail.courseNo ?: summary.courseNo,
courseTitle = detail.courseTitle ?: summary.courseTitle,
)
}
}
}
}
private fun courseError(ex: RestClientResponseException): Exception =
when (ex.statusCode.value()) {
403 -> ForbiddenException("Курс недоступен")
404 -> NotFoundException("Курс не найден")
else -> ex
}
@Service
class IdentityClient(
@Value("\${lms.identity.base-url}") baseUrl: String,
@Value("\${lms.internal.token:dev-internal-token}") private val internalToken: String,
) {
private val client = restClient(baseUrl)
@@ -67,11 +129,39 @@ class IdentityClient(
.retrieve()
.body(type) ?: emptyList()
}
/** Внутренний контракт identity-service: контекст студента, без пользовательского токена. */
fun getStudentContext(studentId: String): StudentContext =
try {
client
.get()
.uri("/api/v1/internal/students/{id}/context", studentId)
.header(INTERNAL_TOKEN_HEADER, internalToken)
.retrieve()
.body(StudentContext::class.java)
?: throw NotFoundException("Студент не найден")
} catch (ex: RestClientResponseException) {
if (ex.statusCode.value() == 404) throw NotFoundException("Студент не найден") else throw ex
}
/** Пакетная подпись авторов: один запрос на весь профиль. */
fun lookupAccounts(ids: Collection<String>): List<AccountInfo> {
if (ids.isEmpty()) return emptyList()
val type = object : ParameterizedTypeReference<List<AccountInfo>>() {}
return client
.post()
.uri("/api/v1/internal/accounts/lookup")
.header(INTERNAL_TOKEN_HEADER, internalToken)
.body(mapOf("ids" to ids.toList()))
.retrieve()
.body(type) ?: emptyList()
}
}
/** Таймауты обязательны: зависший downstream держит JDBC-соединение из пула и вешает сервис. */
private const val CONNECT_TIMEOUT_SECONDS = 3L
private const val READ_TIMEOUT_SECONDS = 30L
private const val INTERNAL_TOKEN_HEADER = "X-Internal-Token"
private fun restClient(baseUrl: String): RestClient =
RestClient

View File

@@ -67,6 +67,45 @@ data class LabInfo(
val defenseAt: String? = null,
val pointsMax: Any? = null,
val levels: List<LevelInfo> = emptyList(),
val courseId: String? = null,
val courseNo: Long? = null,
val courseTitle: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CourseInfo(
val id: String = "",
val courseNo: Long = 0,
val title: String = "",
val status: String = "",
val groups: List<CourseGroupInfo> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CourseGroupInfo(
val groupId: String = "",
val removedAt: String? = null,
)
/** Внутренний контекст студента из identity-service. */
@JsonIgnoreProperties(ignoreUnknown = true)
data class StudentContext(
val id: String = "",
val login: String = "",
val fullName: String = "",
val studentNo: String = "",
val status: String = "",
val enrollmentYear: Short? = null,
val groupId: String? = null,
val groupName: String? = null,
)
/** Минимальные данные аккаунта для подписи авторов оценок и комментариев. */
@JsonIgnoreProperties(ignoreUnknown = true)
data class AccountInfo(
val id: String = "",
val login: String = "",
val role: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)

View File

@@ -20,7 +20,10 @@ class GradeService(
private val gradeComments: GradeCommentRepository,
) {
@Transactional
fun setGrade(request: GradeSetRequest): GradeResponse {
fun setGrade(
request: GradeSetRequest,
authorAccountId: UUID? = null,
): GradeResponse {
if (request.score < BigDecimal.ZERO || request.score > BigDecimal("5")) {
throw BadRequestException("Оценка должна быть в диапазоне 0–5")
}
@@ -30,9 +33,10 @@ class GradeService(
val grade = grades.findById(key).orElseGet { LabGrade(studentId = key.studentId, labId = key.labId) }
grade.score = request.score
grade.generalComment = request.generalComment.trim()
grade.updatedByAccountId = authorAccountId
grade.updatedAt = java.time.Instant.now()
grades.save(grade)
replaceComments(sid, lid, request.comments)
replaceComments(sid, lid, request.comments, authorAccountId)
return GradeResponse(
request.studentId,
request.labId,
@@ -46,13 +50,22 @@ class GradeService(
sid: UUID,
lid: UUID,
inputs: List<CommentInput>,
authorAccountId: UUID?,
) {
val existing = gradeComments.findByStudentIdAndLabId(sid, lid).map { it.text + "|" + it.tone }.toSet()
val incoming = inputs.filter { it.text.isNotBlank() }.map { it.text.trim() to normalizeTone(it.tone) }
if (existing.toSet() == incoming.toSet()) return
gradeComments.deleteByStudentIdAndLabId(sid, lid)
for ((text, tone) in incoming) {
gradeComments.save(GradeComment(studentId = sid, labId = lid, text = text, tone = tone))
gradeComments.save(
GradeComment(
studentId = sid,
labId = lid,
text = text,
tone = tone,
authorAccountId = authorAccountId,
),
)
val t = templates.findByText(text)
if (t == null) {
templates.save(CommentTemplate(text = text, tone = tone, useCount = 1))

View File

@@ -79,7 +79,8 @@ class ProgressController(
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun setGrade(
@Valid @RequestBody body: GradeSetRequest,
): GradeResponse = grades.setGrade(body)
@AuthenticationPrincipal jwt: Jwt,
): GradeResponse = grades.setGrade(body, UUID.fromString(jwt.subject))
@GetMapping("/grades")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")

View File

@@ -25,5 +25,6 @@ class GradeComment(
@Column(name = "lab_id", nullable = false) var labId: UUID = UUID(0, 0),
@Column(nullable = false) var text: String = "",
@Column(nullable = false, length = 8) var tone: String = "neutral",
@Column(name = "author_account_id") var authorAccountId: UUID? = null,
@Column(name = "created_at", nullable = false) var createdAt: Instant = Instant.now(),
)

View File

@@ -18,6 +18,7 @@ class LabGrade(
@Id @Column(nullable = false) var labId: UUID = UUID(0, 0),
@Column(nullable = false) var score: BigDecimal = BigDecimal.ZERO,
@Column(name = "general_comment", nullable = false) var generalComment: String = "",
@Column(name = "updated_by_account_id") var updatedByAccountId: UUID? = null,
@Column(nullable = false) var updatedAt: Instant = Instant.now(),
)

View File

@@ -0,0 +1,68 @@
package ru.ysu.lms.progress.profile
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
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.RestController
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.UUID
@RestController
@RequestMapping("/api/v1/courses")
class ProfileController(
private val profiles: StudentProfileService,
) {
@GetMapping("/{courseId}/students/{studentId}/profile")
@PreAuthorize("hasAnyRole('ADMIN','TEACHER','STUDENT')")
fun profile(
@PathVariable courseId: UUID,
@PathVariable studentId: UUID,
@AuthenticationPrincipal jwt: Jwt,
): StudentCourseProfile =
profiles.profile(
courseId = courseId,
studentId = studentId,
callerId = UUID.fromString(jwt.subject),
callerRole = role(jwt),
token = jwt.tokenValue,
)
@GetMapping("/{courseId}/submissions/{submissionId}/download")
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun download(
@PathVariable courseId: UUID,
@PathVariable submissionId: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<ByteArray> {
val (bytes, name) =
profiles.downloadSubmission(
courseId = courseId,
submissionId = submissionId,
callerId = UUID.fromString(jwt.subject),
callerRole = role(jwt),
token = jwt.tokenValue,
)
return attachment(bytes, name)
}
private fun role(jwt: Jwt): String = jwt.getClaimAsString("role") ?: ""
private fun attachment(
bytes: ByteArray,
name: String,
): ResponseEntity<ByteArray> =
ResponseEntity
.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''${URLEncoder.encode(name, StandardCharsets.UTF_8)}",
).contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(bytes)
}

View File

@@ -0,0 +1,95 @@
package ru.ysu.lms.progress.profile
/** Краткие данные курса в профиле студента. */
data class ProfileCourse(
val id: String,
val courseNo: Long,
val title: String,
)
data class ProfileStudent(
val id: String,
val studentNo: String,
val fullName: String,
val groupId: String?,
val groupName: String?,
val status: String,
val enrollmentYear: Short?,
)
data class ProfileSummary(
val labsTotal: Int,
val submitted: Int,
val graded: Int,
val averageScore: Double?,
val defensesTotal: Int,
val defensesReady: Int,
)
data class ProfileVariant(
val number: String,
val title: String,
)
data class ProfileLevel(
val title: String,
)
data class ProfileAuthor(
val id: String,
val login: String,
)
data class ProfileGrade(
val score: String,
val published: Boolean,
val generalComment: String,
val author: ProfileAuthor?,
)
data class ProfileComment(
val id: String,
val text: String,
val tone: String,
val author: ProfileAuthor?,
val createdAt: String,
)
data class ProfileDefense(
val sessionId: String,
val status: String,
val readyReason: String?,
val code: String?,
val assignmentMd: String,
val secondsLeft: Long?,
)
data class ProfileSubmission(
val id: String,
val seq: Int,
val createdAt: String,
val files: List<String>,
val downloadUrl: String,
)
data class ProfileLab(
val id: String,
val number: Int,
val title: String,
val deadlineAt: String?,
val variant: ProfileVariant?,
val level: ProfileLevel?,
val attemptsCount: Int,
val lastSubmissionAt: String?,
val grade: ProfileGrade?,
val comments: List<ProfileComment>,
val defense: ProfileDefense?,
val submissions: List<ProfileSubmission>,
)
data class StudentCourseProfile(
val course: ProfileCourse,
val student: ProfileStudent,
val summary: ProfileSummary,
val labs: List<ProfileLab>,
)

View File

@@ -0,0 +1,431 @@
package ru.ysu.lms.progress.profile
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.ForbiddenException
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.progress.AcademicClient
import ru.ysu.lms.progress.CourseInfo
import ru.ysu.lms.progress.DefenseService
import ru.ysu.lms.progress.DefenseStatus
import ru.ysu.lms.progress.IdentityClient
import ru.ysu.lms.progress.LabInfo
import ru.ysu.lms.progress.StudentContext
import ru.ysu.lms.progress.SubmissionService
import ru.ysu.lms.progress.SubmissionStorage
import ru.ysu.lms.progress.domain.DefenseSession
import ru.ysu.lms.progress.domain.GradeComment
import ru.ysu.lms.progress.domain.LabGrade
import ru.ysu.lms.progress.domain.Submission
import ru.ysu.lms.progress.repo.DefenseSessionRepository
import ru.ysu.lms.progress.repo.GradeCommentRepository
import ru.ysu.lms.progress.repo.GradePublicationRepository
import ru.ysu.lms.progress.repo.GradePublicationStudentRepository
import ru.ysu.lms.progress.repo.LabGradeRepository
import ru.ysu.lms.progress.repo.StudentLabLevelRepository
import ru.ysu.lms.progress.repo.SubmissionRepository
import ru.ysu.lms.progress.repo.VariantAssignmentRepository
import java.time.Duration
import java.time.Instant
import java.util.UUID
/**
* Профиль студента на курсе для преподавателя/администратора и самого студента.
* Все данные собираются одним вызовом: оценки, комментарии, попытки сдач, защиты,
* назначенные варианты и состояние публикации.
*/
@Suppress("LongParameterList", "TooManyFunctions")
@Service
class StudentProfileService(
private val academic: AcademicClient,
private val identity: IdentityClient,
private val grades: LabGradeRepository,
private val comments: GradeCommentRepository,
private val submissions: SubmissionRepository,
private val storage: SubmissionStorage,
private val defenses: DefenseSessionRepository,
private val assignments: VariantAssignmentRepository,
private val levels: StudentLabLevelRepository,
private val publications: GradePublicationRepository,
private val studentPublications: GradePublicationStudentRepository,
private val submissionService: SubmissionService,
) {
@Transactional(readOnly = true)
fun profile(
courseId: UUID,
studentId: UUID,
callerId: UUID,
callerRole: String,
token: String,
): StudentCourseProfile {
val role = normalizeRole(callerRole)
val course = academic.getCourse(courseId.toString(), token)
val context = identity.getStudentContext(studentId.toString())
requireProfileAccess(course, context, studentId, callerId, role)
val labs = courseLabs(courseId, token)
val labIds = labs.map { it.first }
val sid = studentId
val visibleGrades = gradesByLab(sid, labIds)
val allComments = commentsByLab(sid, labIds)
val submissionsByLab = submissionsByLab(sid, labIds)
val defensesByLab = defensesByLab(sid, labIds)
val assignmentsByLab = assignmentsByLab(sid, labIds)
val levelsByLab = levelsByLab(sid, labIds)
val published = publishedLabs(context.groupId, sid, labIds)
val showUnpublished = role == ROLE_ADMIN || role == ROLE_TEACHER
val authorIds = mutableSetOf<UUID>()
for ((labId, _) in labs) {
if (showUnpublished || labId in published) {
visibleGrades[labId]?.updatedByAccountId?.let { authorIds.add(it) }
allComments[labId].orEmpty().forEach { it.authorAccountId?.let(authorIds::add) }
}
}
val logins = lookupLogins(authorIds)
val profileLabs =
labs.map { (labId, lab) ->
buildLab(
courseId = courseId,
studentId = sid,
lab = lab,
labId = labId,
grade = visibleGrades[labId],
comments = allComments[labId].orEmpty(),
submissions = submissionsByLab[labId].orEmpty(),
defense = defensesByLab[labId],
variantId = assignmentsByLab[labId],
levelId = levelsByLab[labId],
published = labId in published,
showUnpublished = showUnpublished,
logins = logins,
)
}
return StudentCourseProfile(
course = ProfileCourse(course.id.ifBlank { courseId.toString() }, course.courseNo, course.title),
student = toStudent(context, studentId),
summary = summary(profileLabs),
labs = profileLabs,
)
}
/** Скачивание чужой сдачи: преподаватель — только для студента из активной группы курса. */
fun downloadSubmission(
courseId: UUID,
submissionId: UUID,
callerId: UUID,
callerRole: String,
token: String,
): Pair<ByteArray, String> {
val role = normalizeRole(callerRole)
if (role != ROLE_ADMIN && role != ROLE_TEACHER) {
forbidden("Нет доступа к этой сдаче")
}
val course = academic.getCourse(courseId.toString(), token)
val submission = submissions.findById(submissionId).orElseThrow { NotFoundException("Сдача не найдена") }
// 403, а не 404: сдача существует, но запрошена в контексте чужого курса.
val courseLabIds =
academic
.getCourseLabs(courseId.toString(), token)
.mapNotNull { runCatching { UUID.fromString(it.id) }.getOrNull() }
.toSet()
if (submission.labId !in courseLabIds) {
forbidden("Сдача не относится к этому курсу")
}
if (role == ROLE_TEACHER) {
val context = identity.getStudentContext(submission.studentId.toString())
val activeGroups = course.groups.filter { it.removedAt == null }.mapTo(HashSet()) { it.groupId }
if (context.groupId == null || context.groupId !in activeGroups) {
forbidden("Студент не состоит в группе курса")
}
}
return submissionService.download(callerId, submissionId, null, isAdmin = true)
}
private fun requireProfileAccess(
course: CourseInfo,
context: StudentContext,
studentId: UUID,
callerId: UUID,
role: String,
) {
when (role) {
ROLE_ADMIN -> Unit
ROLE_TEACHER -> {
val groupId = context.groupId
val attached =
groupId != null && course.groups.any { it.groupId == groupId && it.removedAt == null }
if (!attached) forbidden("Студент не состоит в группе курса")
}
ROLE_STUDENT -> {
if (studentId != callerId) forbidden("Можно смотреть только свой профиль")
}
else -> forbidden("Нет доступа к профилю студента")
}
}
private fun forbidden(message: String): Nothing = throw ForbiddenException(message)
private fun courseLabs(
courseId: UUID,
token: String,
): List<Pair<UUID, LabInfo>> =
academic
.getCourseLabs(courseId.toString(), token)
.mapNotNull { lab ->
runCatching { UUID.fromString(lab.id) }.getOrNull()?.let { it to lab }
}
private fun gradesByLab(
studentId: UUID,
labIds: List<UUID>,
): Map<UUID, LabGrade> =
if (labIds.isEmpty()) {
emptyMap()
} else {
grades.findByStudentIdAndLabIdIn(studentId, labIds).associateBy { it.labId }
}
private fun commentsByLab(
studentId: UUID,
labIds: List<UUID>,
): Map<UUID, List<GradeComment>> =
if (labIds.isEmpty()) {
emptyMap()
} else {
comments.findByStudentIdAndLabIdIn(studentId, labIds).groupBy { it.labId }
}
private fun submissionsByLab(
studentId: UUID,
labIds: List<UUID>,
): Map<UUID, List<Submission>> =
if (labIds.isEmpty()) {
emptyMap()
} else {
submissions.findByStudentIdAndLabIdIn(studentId, labIds).groupBy { it.labId }
}
private fun defensesByLab(
studentId: UUID,
labIds: List<UUID>,
): Map<UUID, DefenseSession> =
if (labIds.isEmpty()) {
emptyMap()
} else {
defenses.findByStudentIdAndLabIdIn(studentId, labIds).associateBy { it.labId }
}
private fun assignmentsByLab(
studentId: UUID,
labIds: List<UUID>,
): Map<UUID, UUID> =
if (labIds.isEmpty()) {
emptyMap()
} else {
assignments
.findByStudentIdAndLabIdIn(studentId, labIds)
.associate { it.labId to it.variantId }
}
private fun levelsByLab(
studentId: UUID,
labIds: List<UUID>,
): Map<UUID, UUID> =
if (labIds.isEmpty()) {
emptyMap()
} else {
levels
.findByStudentIdAndLabIdIn(studentId, labIds)
.associate { it.labId to it.labLevelId }
}
private fun publishedLabs(
rawGroupId: String?,
studentId: UUID,
labIds: List<UUID>,
): Set<UUID> {
if (labIds.isEmpty()) return emptySet()
val groupId = rawGroupId?.let { runCatching { UUID.fromString(it) }.getOrNull() } ?: return emptySet()
val all = publications.findByGroupIdAndLabIdIn(groupId, labIds).mapTo(HashSet()) { it.labId }
studentPublications
.findByGroupIdAndLabIdIn(groupId, labIds)
.filter { it.studentId == studentId }
.forEach { all.add(it.labId) }
return all
}
private fun lookupLogins(authorIds: Set<UUID>): Map<String, String> {
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 } }
}
@Suppress("LongParameterList")
private fun buildLab(
courseId: UUID,
studentId: UUID,
lab: LabInfo,
labId: UUID,
grade: LabGrade?,
comments: List<GradeComment>,
submissions: List<Submission>,
defense: DefenseSession?,
variantId: UUID?,
levelId: UUID?,
published: Boolean,
showUnpublished: Boolean,
logins: Map<String, String>,
): ProfileLab {
val canSeeGrade = showUnpublished || published
val sortedSubmissions = submissions.sortedByDescending { it.seq }
val lastSubmissionAt =
sortedSubmissions.maxWithOrNull(compareBy<Submission> { it.createdAt }.thenBy { it.seq })?.createdAt?.toString()
val gradeDto =
grade
?.takeIf { canSeeGrade }
?.let {
ProfileGrade(
score = it.score.toPlainString(),
published = published,
generalComment = it.generalComment,
author = authorOf(it.updatedByAccountId, logins),
)
}
val commentDtos =
if (canSeeGrade) {
comments
.sortedBy { it.createdAt }
.map {
ProfileComment(
id = it.id.toString(),
text = it.text,
tone = it.tone,
author = authorOf(it.authorAccountId, logins),
createdAt = it.createdAt.toString(),
)
}
} else {
emptyList()
}
return ProfileLab(
id = lab.id.ifBlank { labId.toString() },
number = lab.number,
title = lab.title,
deadlineAt = lab.deadlineAt,
variant = variantOf(lab, variantId),
level = levelOf(lab, levelId),
attemptsCount = sortedSubmissions.size,
lastSubmissionAt = lastSubmissionAt,
grade = gradeDto,
comments = commentDtos,
defense = defense?.let { toDefense(it) },
submissions =
sortedSubmissions.map { s ->
ProfileSubmission(
id = s.id.toString(),
seq = s.seq,
createdAt = s.createdAt.toString(),
files = storage.listFiles(storage.dirFor(studentId, s.labId, s.seq)),
downloadUrl = "/api/v1/courses/$courseId/submissions/${s.id}/download",
)
},
)
}
private fun variantOf(
lab: LabInfo,
variantId: UUID?,
): ProfileVariant? {
if (variantId == null) return null
val variant =
lab.levels
.asSequence()
.flatMap { it.variants.asSequence() }
.firstOrNull { it.id == variantId.toString() }
?: return null
return ProfileVariant(variant.number, variant.title)
}
private fun levelOf(
lab: LabInfo,
levelId: UUID?,
): ProfileLevel? {
if (levelId == null) return null
val level = lab.levels.firstOrNull { it.id == levelId.toString() } ?: return null
return ProfileLevel(level.title)
}
private fun toDefense(session: DefenseSession): ProfileDefense {
val secondsLeft =
if (session.status == DefenseStatus.started.name) {
val started = session.startedAt
if (started == null) {
null
} else {
Duration
.between(Instant.now(), started.plus(DefenseService.DURATION))
.seconds
.coerceAtLeast(0)
}
} else {
null
}
return ProfileDefense(
sessionId = session.id.toString(),
status = session.status,
readyReason = session.readyReason,
code = session.code,
assignmentMd = session.assignmentMd,
secondsLeft = secondsLeft,
)
}
private fun toStudent(
context: StudentContext,
studentId: UUID,
): ProfileStudent =
ProfileStudent(
id = context.id.ifBlank { studentId.toString() },
studentNo = context.studentNo,
fullName = context.fullName,
groupId = context.groupId,
groupName = context.groupName,
status = context.status,
enrollmentYear = context.enrollmentYear,
)
private fun summary(labs: List<ProfileLab>): ProfileSummary {
val grades = labs.mapNotNull { it.grade }.mapNotNull { it.score.toDoubleOrNull() }
return ProfileSummary(
labsTotal = labs.size,
submitted = labs.count { it.submissions.isNotEmpty() },
graded = labs.count { it.grade != null },
averageScore = if (grades.isEmpty()) null else grades.average(),
defensesTotal = labs.count { it.defense != null },
defensesReady = labs.count { it.defense?.status == DefenseStatus.ready.name },
)
}
private fun authorOf(
authorId: UUID?,
logins: Map<String, String>,
): ProfileAuthor? =
authorId?.let {
val key = it.toString()
ProfileAuthor(key, logins[key] ?: UNKNOWN_AUTHOR)
}
private fun normalizeRole(role: String): String = role.removePrefix("ROLE_").uppercase()
private companion object {
const val ROLE_ADMIN = "ADMIN"
const val ROLE_TEACHER = "TEACHER"
const val ROLE_STUDENT = "STUDENT"
const val UNKNOWN_AUTHOR = "—"
}
}

View File

@@ -19,6 +19,11 @@ interface GradeCommentRepository : JpaRepository<GradeComment, UUID> {
labId: UUID,
): List<GradeComment>
fun findByStudentIdAndLabIdIn(
studentId: UUID,
labIds: Collection<UUID>,
): List<GradeComment>
fun deleteByStudentIdAndLabId(
studentId: UUID,
labId: UUID,

View File

@@ -14,6 +14,11 @@ interface DefenseSessionRepository : JpaRepository<DefenseSession, UUID> {
labId: UUID,
): DefenseSession?
fun findByStudentIdAndLabIdIn(
studentId: UUID,
labIds: Collection<UUID>,
): List<DefenseSession>
fun findByStudentId(studentId: UUID): List<DefenseSession>
fun findByStatus(status: String): List<DefenseSession>

View File

@@ -10,4 +10,9 @@ interface GradePublicationRepository : JpaRepository<GradePublication, GradePubl
labId: UUID,
groupId: UUID,
): GradePublication?
fun findByGroupIdAndLabIdIn(
groupId: UUID,
labIds: Collection<UUID>,
): List<GradePublication>
}

View File

@@ -11,6 +11,11 @@ interface GradePublicationStudentRepository : JpaRepository<GradePublicationStud
labId: UUID,
): List<GradePublicationStudent>
fun findByGroupIdAndLabIdIn(
groupId: UUID,
labIds: Collection<UUID>,
): List<GradePublicationStudent>
fun deleteByGroupIdAndLabId(
groupId: UUID,
labId: UUID,

View File

@@ -14,4 +14,9 @@ interface LabGradeRepository : JpaRepository<LabGrade, LabGradeKey> {
studentId: UUID,
labId: UUID,
): LabGrade?
fun findByStudentIdAndLabIdIn(
studentId: UUID,
labIds: Collection<UUID>,
): List<LabGrade>
}

View File

@@ -15,5 +15,10 @@ interface StudentLabLevelRepository : JpaRepository<StudentLabLevel, StudentLabL
labId: UUID,
): StudentLabLevel?
fun findByStudentIdAndLabIdIn(
studentId: UUID,
labIds: Collection<UUID>,
): List<StudentLabLevel>
fun deleteByLabId(labId: UUID)
}

View File

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

View File

@@ -14,5 +14,10 @@ interface VariantAssignmentRepository : JpaRepository<VariantAssignment, UUID> {
labId: UUID,
): VariantAssignment?
fun findByStudentIdAndLabIdIn(
studentId: UUID,
labIds: Collection<UUID>,
): List<VariantAssignment>
fun deleteByLabId(labId: UUID)
}

View File

@@ -37,4 +37,6 @@ lms:
academic:
base-url: ${LMS_ACADEMIC_URI:http://localhost:8082}
identity:
base-url: ${LMS_IDENTITY_URI:http://localhost:8081}
base-url: ${LMS_IDENTITY_URI:http://localhost:8081}
internal:
token: ${LMS_INTERNAL_TOKEN:dev-internal-token}

View File

@@ -0,0 +1,327 @@
package ru.ysu.lms.progress
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyCollection
import org.mockito.Mockito.`when`
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.postgresql.PostgreSQLContainer
import ru.ysu.lms.common.ForbiddenException
import ru.ysu.lms.progress.domain.GradePublicationStudent
import ru.ysu.lms.progress.domain.StudentLabLevel
import ru.ysu.lms.progress.domain.Submission
import ru.ysu.lms.progress.domain.VariantAssignment
import ru.ysu.lms.progress.profile.StudentProfileService
import ru.ysu.lms.progress.repo.GradePublicationStudentRepository
import ru.ysu.lms.progress.repo.StudentLabLevelRepository
import ru.ysu.lms.progress.repo.SubmissionRepository
import ru.ysu.lms.progress.repo.VariantAssignmentRepository
import java.math.BigDecimal
import java.time.Instant
import java.util.UUID
/**
* Профиль студента курса: реальная PostgreSQL/Flyway, academic и identity подменены.
* Проверяются доступ, публикация оценок, все попытки сдач и скачивание.
*/
@Testcontainers
@SpringBootTest(properties = ["lms.submissions.dir=build/test-profile-submissions"])
class StudentProfileIntegrationTest {
companion object {
@Container
@ServiceConnection
@JvmStatic
val postgres = PostgreSQLContainer("postgres:18")
private const val TEACHER_TOKEN = "teacher-token"
private const val STUDENT_TOKEN = "student-token"
private const val ADMIN_TOKEN = "admin-token"
}
@Autowired
private lateinit var profiles: StudentProfileService
@Autowired
private lateinit var grades: GradeService
@Autowired
private lateinit var submissions: SubmissionRepository
@Autowired
private lateinit var studentPublications: GradePublicationStudentRepository
@Autowired
private lateinit var assignments: VariantAssignmentRepository
@Autowired
private lateinit var levels: StudentLabLevelRepository
@Autowired
private lateinit var storage: SubmissionStorage
@MockitoBean
private lateinit var academic: AcademicClient
@MockitoBean
private lateinit var identity: IdentityClient
@Test
fun `teacher sees unpublished grade, authors and all submission attempts`() {
val courseId = UUID.randomUUID()
val groupId = UUID.randomUUID()
val studentId = UUID.randomUUID()
val labId = UUID.randomUUID()
val variantId = UUID.randomUUID()
val levelId = UUID.randomUUID()
val authorId = UUID.randomUUID()
stubCourse(courseId, groupId, TEACHER_TOKEN, listOf(labInfo(labId, 1, variantId, levelId)))
stubStudent(studentId, groupId)
assignments.save(VariantAssignment(studentId = studentId, labId = labId, variantId = variantId))
levels.save(StudentLabLevel(studentId = studentId, labId = labId, labLevelId = levelId))
`when`(identity.lookupAccounts(anyCollection())).thenReturn(
listOf(AccountInfo(authorId.toString(), "teacher_login", "TEACHER")),
)
grades.setGrade(
GradeSetRequest(
studentId = studentId.toString(),
labId = labId.toString(),
score = BigDecimal("4.5"),
comments = listOf(CommentInput("Хорошая работа", "positive")),
generalComment = "Зачтено",
),
authorId,
)
val first = saveSubmission(studentId, labId, 1, "2026-10-01T10:00:00Z", "main.c")
val second = saveSubmission(studentId, labId, 2, "2026-10-02T10:00:00Z", "main.c", "README.md")
val profile = profiles.profile(courseId, studentId, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN)
assertThat(profile.course.title).isEqualTo("Алгоритмы")
assertThat(profile.student.groupId).isEqualTo(groupId.toString())
assertThat(profile.summary.labsTotal).isEqualTo(1)
assertThat(profile.summary.submitted).isEqualTo(1)
assertThat(profile.summary.graded).isEqualTo(1)
assertThat(profile.summary.averageScore).isEqualTo(4.5)
assertThat(profile.labs).hasSize(1)
val lab = profile.labs.single()
assertThat(lab.variant?.number).isEqualTo("7")
assertThat(lab.level?.title).isEqualTo("Базовый")
assertThat(lab.attemptsCount).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)
.isEqualTo("/api/v1/courses/$courseId/submissions/${second.id}/download")
val grade = checkNotNull(lab.grade)
assertThat(grade.score).isEqualTo("4.5")
assertThat(grade.published).isFalse()
assertThat(grade.author?.login).isEqualTo("teacher_login")
assertThat(lab.comments).hasSize(1)
val commentAuthor = lab.comments.single().author
assertThat(commentAuthor?.login).isEqualTo("teacher_login")
val (bytes, name) =
profiles.downloadSubmission(courseId, second.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN)
assertThat(name).endsWith(".zip")
assertThat(bytes).isNotEmpty()
assertThat(first.id).isNotEqualTo(second.id)
}
@Test
fun `student sees only published grade and comments of own profile`() {
val courseId = UUID.randomUUID()
val groupId = UUID.randomUUID()
val studentId = UUID.randomUUID()
val hiddenLab = UUID.randomUUID()
val publishedLab = UUID.randomUUID()
stubCourse(
courseId,
groupId,
STUDENT_TOKEN,
listOf(
labInfo(hiddenLab, 1, UUID.randomUUID(), UUID.randomUUID()),
labInfo(publishedLab, 2, UUID.randomUUID(), UUID.randomUUID()),
),
)
stubStudent(studentId, groupId)
grades.setGrade(gradeRequest(studentId, hiddenLab, "2"))
grades.setGrade(gradeRequest(studentId, publishedLab, "5"))
studentPublications.save(GradePublicationStudent(groupId, publishedLab, studentId))
val profile = profiles.profile(courseId, studentId, studentId, "STUDENT", STUDENT_TOKEN)
val hidden = profile.labs.first { it.id == hiddenLab.toString() }
val published = profile.labs.first { it.id == publishedLab.toString() }
assertThat(hidden.grade).isNull()
assertThat(hidden.comments).isEmpty()
assertThat(published.grade?.score).isEqualTo("5.0")
assertThat(published.grade?.published).isTrue()
assertThat(published.comments).hasSize(1)
assertThat(profile.summary.graded).isEqualTo(1)
assertThat(profile.summary.averageScore).isEqualTo(5.0)
assertThatThrownBy {
profiles.profile(courseId, UUID.randomUUID(), studentId, "STUDENT", STUDENT_TOKEN)
}.isInstanceOf(ForbiddenException::class.java)
}
@Test
fun `teacher of another course gets forbidden`() {
val courseId = UUID.randomUUID()
`when`(academic.getCourse(courseId.toString(), "foreign-token"))
.thenThrow(ForbiddenException("Курс недоступен"))
assertThatThrownBy {
profiles.profile(courseId, UUID.randomUUID(), UUID.randomUUID(), "TEACHER", "foreign-token")
}.isInstanceOf(ForbiddenException::class.java)
}
@Test
fun `teacher cannot read or download student from group outside course but admin can`() {
val courseId = UUID.randomUUID()
val courseGroup = UUID.randomUUID()
val otherGroup = UUID.randomUUID()
val studentId = UUID.randomUUID()
val labId = UUID.randomUUID()
stubCourse(courseId, courseGroup, TEACHER_TOKEN, listOf(labInfo(labId, 1, UUID.randomUUID(), UUID.randomUUID())))
stubCourse(courseId, courseGroup, ADMIN_TOKEN, listOf(labInfo(labId, 1, UUID.randomUUID(), UUID.randomUUID())))
`when`(identity.getStudentContext(studentId.toString()))
.thenReturn(studentContext(studentId, otherGroup))
grades.setGrade(gradeRequest(studentId, labId, "4"))
val submission = saveSubmission(studentId, labId, 1, "2026-10-03T10:00:00Z", "main.c")
assertThatThrownBy {
profiles.profile(courseId, studentId, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN)
}.isInstanceOf(ForbiddenException::class.java)
assertThatThrownBy {
profiles.downloadSubmission(courseId, submission.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN)
}.isInstanceOf(ForbiddenException::class.java)
val adminProfile = profiles.profile(courseId, studentId, UUID.randomUUID(), "ADMIN", ADMIN_TOKEN)
assertThat(adminProfile.student.groupId).isEqualTo(otherGroup.toString())
val (bytes, _) =
profiles.downloadSubmission(courseId, submission.id, UUID.randomUUID(), "ADMIN", ADMIN_TOKEN)
assertThat(bytes).isNotEmpty()
}
@Test
fun `download rejects submission from another course lab and allows current course lab`() {
val courseId = UUID.randomUUID()
val groupId = UUID.randomUUID()
val studentId = UUID.randomUUID()
val courseLabId = UUID.randomUUID()
val foreignLabId = UUID.randomUUID()
stubCourse(courseId, groupId, TEACHER_TOKEN, listOf(labInfo(courseLabId, 1, UUID.randomUUID(), UUID.randomUUID())))
stubStudent(studentId, groupId)
val foreign = saveSubmission(studentId, foreignLabId, 1, "2026-10-04T10:00:00Z", "main.c")
val own = saveSubmission(studentId, courseLabId, 1, "2026-10-05T10:00:00Z", "main.c")
assertThatThrownBy {
profiles.downloadSubmission(courseId, foreign.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN)
}.isInstanceOf(ForbiddenException::class.java)
val (bytes, name) =
profiles.downloadSubmission(courseId, own.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN)
assertThat(bytes).isNotEmpty()
assertThat(name).endsWith(".zip")
}
private fun stubCourse(
courseId: UUID,
groupId: UUID,
token: String,
labList: List<LabInfo> = listOf(labInfo(UUID.randomUUID(), 1, UUID.randomUUID(), UUID.randomUUID())),
) {
`when`(academic.getCourse(courseId.toString(), token)).thenReturn(
CourseInfo(
id = courseId.toString(),
courseNo = 42,
title = "Алгоритмы",
status = "active",
groups = listOf(CourseGroupInfo(groupId.toString(), null)),
),
)
`when`(academic.getCourseLabs(courseId.toString(), token)).thenReturn(labList)
}
private fun stubStudent(
studentId: UUID,
groupId: UUID,
) {
`when`(identity.getStudentContext(studentId.toString()))
.thenReturn(studentContext(studentId, groupId))
}
private fun studentContext(
studentId: UUID,
groupId: UUID,
): StudentContext =
StudentContext(
id = studentId.toString(),
login = "student",
fullName = "Иванов Иван",
studentNo = "2024-001",
status = "active",
enrollmentYear = 2024,
groupId = groupId.toString(),
groupName = "Группа",
)
private fun labInfo(
labId: UUID,
number: Int,
variantId: UUID,
levelId: UUID,
): LabInfo =
LabInfo(
id = labId.toString(),
number = number,
title = "Лабораторная $number",
deadlineAt = "2026-12-01T00:00:00Z",
levels =
listOf(
LevelInfo(
id = levelId.toString(),
code = "base",
title = "Базовый",
variants = listOf(VariantInfo(id = variantId.toString(), number = "7", title = "Вариант 7")),
),
),
)
private fun gradeRequest(
studentId: UUID,
labId: UUID,
score: String,
): GradeSetRequest =
GradeSetRequest(
studentId = studentId.toString(),
labId = labId.toString(),
score = BigDecimal(score),
comments = listOf(CommentInput("Комментарий", "neutral")),
generalComment = "",
)
private fun saveSubmission(
studentId: UUID,
labId: UUID,
seq: Int,
createdAt: String,
vararg files: String,
): Submission {
val stored = storage.store(studentId, labId, seq, files.map { it to "code".toByteArray() })
assertThat(stored.files).hasSize(files.size)
return submissions.save(
Submission(studentId = studentId, labId = labId, seq = seq, createdAt = Instant.parse(createdAt)),
)
}
}