diff --git a/services/gateway/src/main/resources/application.yml b/services/gateway/src/main/resources/application.yml index 5c9c566..35ea2ae 100644 --- a/services/gateway/src/main/resources/application.yml +++ b/services/gateway/src/main/resources/application.yml @@ -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: diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AcademicClient.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AcademicClient.kt index 51f5391..c12021a 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AcademicClient.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AcademicClient.kt @@ -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 { + val type = object : ParameterizedTypeReference>() {} + 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): List { + if (ids.isEmpty()) return emptyList() + val type = object : ParameterizedTypeReference>() {} + 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 diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt index e22f51d..00fb49a 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt @@ -67,6 +67,45 @@ data class LabInfo( val defenseAt: String? = null, val pointsMax: Any? = null, val levels: List = 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 = 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) diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradeService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradeService.kt index 788ff8d..4e05bf0 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradeService.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradeService.kt @@ -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, + 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)) diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt index 925fe37..9273399 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt @@ -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')") diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Comments.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Comments.kt index 90eb97e..ed56ad6 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Comments.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Comments.kt @@ -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(), ) diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/LabGrade.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/LabGrade.kt index 74c5d51..d070d9a 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/LabGrade.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/LabGrade.kt @@ -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(), ) diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileController.kt new file mode 100644 index 0000000..c1c06a5 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileController.kt @@ -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 { + 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 = + ResponseEntity + .ok() + .header( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename*=UTF-8''${URLEncoder.encode(name, StandardCharsets.UTF_8)}", + ).contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(bytes) +} diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileDtos.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileDtos.kt new file mode 100644 index 0000000..a2489e2 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileDtos.kt @@ -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, + 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, + val defense: ProfileDefense?, + val submissions: List, +) + +data class StudentCourseProfile( + val course: ProfileCourse, + val student: ProfileStudent, + val summary: ProfileSummary, + val labs: List, +) diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/StudentProfileService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/StudentProfileService.kt new file mode 100644 index 0000000..292a627 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/StudentProfileService.kt @@ -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() + 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 { + 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> = + academic + .getCourseLabs(courseId.toString(), token) + .mapNotNull { lab -> + runCatching { UUID.fromString(lab.id) }.getOrNull()?.let { it to lab } + } + + private fun gradesByLab( + studentId: UUID, + labIds: List, + ): Map = + if (labIds.isEmpty()) { + emptyMap() + } else { + grades.findByStudentIdAndLabIdIn(studentId, labIds).associateBy { it.labId } + } + + private fun commentsByLab( + studentId: UUID, + labIds: List, + ): Map> = + if (labIds.isEmpty()) { + emptyMap() + } else { + comments.findByStudentIdAndLabIdIn(studentId, labIds).groupBy { it.labId } + } + + private fun submissionsByLab( + studentId: UUID, + labIds: List, + ): Map> = + if (labIds.isEmpty()) { + emptyMap() + } else { + submissions.findByStudentIdAndLabIdIn(studentId, labIds).groupBy { it.labId } + } + + private fun defensesByLab( + studentId: UUID, + labIds: List, + ): Map = + if (labIds.isEmpty()) { + emptyMap() + } else { + defenses.findByStudentIdAndLabIdIn(studentId, labIds).associateBy { it.labId } + } + + private fun assignmentsByLab( + studentId: UUID, + labIds: List, + ): Map = + if (labIds.isEmpty()) { + emptyMap() + } else { + assignments + .findByStudentIdAndLabIdIn(studentId, labIds) + .associate { it.labId to it.variantId } + } + + private fun levelsByLab( + studentId: UUID, + labIds: List, + ): Map = + if (labIds.isEmpty()) { + emptyMap() + } else { + levels + .findByStudentIdAndLabIdIn(studentId, labIds) + .associate { it.labId to it.labLevelId } + } + + private fun publishedLabs( + rawGroupId: String?, + studentId: UUID, + labIds: List, + ): Set { + 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): Map { + 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, + submissions: List, + defense: DefenseSession?, + variantId: UUID?, + levelId: UUID?, + published: Boolean, + showUnpublished: Boolean, + logins: Map, + ): ProfileLab { + val canSeeGrade = showUnpublished || published + val sortedSubmissions = submissions.sortedByDescending { it.seq } + val lastSubmissionAt = + sortedSubmissions.maxWithOrNull(compareBy { 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): 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, + ): 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 = "—" + } +} diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/CommentRepositories.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/CommentRepositories.kt index b49ac89..7c72cf8 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/CommentRepositories.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/CommentRepositories.kt @@ -19,6 +19,11 @@ interface GradeCommentRepository : JpaRepository { labId: UUID, ): List + fun findByStudentIdAndLabIdIn( + studentId: UUID, + labIds: Collection, + ): List + fun deleteByStudentIdAndLabId( studentId: UUID, labId: UUID, diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/DefenseSessionRepository.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/DefenseSessionRepository.kt index 754e42e..153f875 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/DefenseSessionRepository.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/DefenseSessionRepository.kt @@ -14,6 +14,11 @@ interface DefenseSessionRepository : JpaRepository { labId: UUID, ): DefenseSession? + fun findByStudentIdAndLabIdIn( + studentId: UUID, + labIds: Collection, + ): List + fun findByStudentId(studentId: UUID): List fun findByStatus(status: String): List diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationRepository.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationRepository.kt index ca31009..f65c6a1 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationRepository.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationRepository.kt @@ -10,4 +10,9 @@ interface GradePublicationRepository : JpaRepository, + ): List } diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationStudentRepository.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationStudentRepository.kt index 7c6fac6..870d2a7 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationStudentRepository.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationStudentRepository.kt @@ -11,6 +11,11 @@ interface GradePublicationStudentRepository : JpaRepository + fun findByGroupIdAndLabIdIn( + groupId: UUID, + labIds: Collection, + ): List + fun deleteByGroupIdAndLabId( groupId: UUID, labId: UUID, diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/LabGradeRepository.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/LabGradeRepository.kt index 45c11c7..84af13f 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/LabGradeRepository.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/LabGradeRepository.kt @@ -14,4 +14,9 @@ interface LabGradeRepository : JpaRepository { studentId: UUID, labId: UUID, ): LabGrade? + + fun findByStudentIdAndLabIdIn( + studentId: UUID, + labIds: Collection, + ): List } diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/StudentLabLevelRepository.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/StudentLabLevelRepository.kt index 27007ea..54047f4 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/StudentLabLevelRepository.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/StudentLabLevelRepository.kt @@ -15,5 +15,10 @@ interface StudentLabLevelRepository : JpaRepository, + ): List + fun deleteByLabId(labId: UUID) } diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/SubmissionRepository.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/SubmissionRepository.kt index 23941fa..8808e14 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/SubmissionRepository.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/SubmissionRepository.kt @@ -12,6 +12,11 @@ interface SubmissionRepository : JpaRepository { labId: UUID, ): List + fun findByStudentIdAndLabIdIn( + studentId: UUID, + labIds: Collection, + ): List + fun findByLabIdOrderByCreatedAtDesc(labId: UUID): List fun findFirstByStudentIdAndLabIdOrderBySeqDesc( diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/VariantAssignmentRepository.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/VariantAssignmentRepository.kt index a7c6d2b..b281c89 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/VariantAssignmentRepository.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/VariantAssignmentRepository.kt @@ -14,5 +14,10 @@ interface VariantAssignmentRepository : JpaRepository { labId: UUID, ): VariantAssignment? + fun findByStudentIdAndLabIdIn( + studentId: UUID, + labIds: Collection, + ): List + fun deleteByLabId(labId: UUID) } diff --git a/services/progress-service/src/main/resources/application.yml b/services/progress-service/src/main/resources/application.yml index c9d0dbf..d0ee587 100644 --- a/services/progress-service/src/main/resources/application.yml +++ b/services/progress-service/src/main/resources/application.yml @@ -37,4 +37,6 @@ lms: academic: base-url: ${LMS_ACADEMIC_URI:http://localhost:8082} identity: - base-url: ${LMS_IDENTITY_URI:http://localhost:8081} \ No newline at end of file + base-url: ${LMS_IDENTITY_URI:http://localhost:8081} + internal: + token: ${LMS_INTERNAL_TOKEN:dev-internal-token} \ No newline at end of file diff --git a/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/StudentProfileIntegrationTest.kt b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/StudentProfileIntegrationTest.kt new file mode 100644 index 0000000..03ff91d --- /dev/null +++ b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/StudentProfileIntegrationTest.kt @@ -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 = 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)), + ) + } +}