feat(progress): профиль студента курса и скачивание попыток
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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')")
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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 = "—"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,4 +10,9 @@ interface GradePublicationRepository : JpaRepository<GradePublication, GradePubl
|
||||
labId: UUID,
|
||||
groupId: UUID,
|
||||
): GradePublication?
|
||||
|
||||
fun findByGroupIdAndLabIdIn(
|
||||
groupId: UUID,
|
||||
labIds: Collection<UUID>,
|
||||
): List<GradePublication>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,4 +14,9 @@ interface LabGradeRepository : JpaRepository<LabGrade, LabGradeKey> {
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): LabGrade?
|
||||
|
||||
fun findByStudentIdAndLabIdIn(
|
||||
studentId: UUID,
|
||||
labIds: Collection<UUID>,
|
||||
): List<LabGrade>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user