feat(progress): профиль студента курса и скачивание попыток
This commit is contained in:
@@ -13,6 +13,10 @@ spring:
|
|||||||
uri: ${LMS_IDENTITY_URI:http://localhost:8081}
|
uri: ${LMS_IDENTITY_URI:http://localhost:8081}
|
||||||
predicates:
|
predicates:
|
||||||
- Path=/api/v1/auth/**,/api/v1/accounts/**,/api/v1/groups/**,/api/v1/students/**,/api/v1/teachers/**
|
- 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
|
- id: academic
|
||||||
uri: ${LMS_ACADEMIC_URI:http://localhost:8082}
|
uri: ${LMS_ACADEMIC_URI:http://localhost:8082}
|
||||||
predicates:
|
predicates:
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import org.springframework.core.ParameterizedTypeReference
|
|||||||
import org.springframework.http.client.JdkClientHttpRequestFactory
|
import org.springframework.http.client.JdkClientHttpRequestFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.web.client.RestClient
|
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 ru.ysu.lms.common.NotFoundException
|
||||||
import java.net.http.HttpClient
|
import java.net.http.HttpClient
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
@@ -38,11 +40,71 @@ class AcademicClient(
|
|||||||
.retrieve()
|
.retrieve()
|
||||||
.body(LabInfo::class.java)
|
.body(LabInfo::class.java)
|
||||||
?: throw NotFoundException("Лабораторная не найдена")
|
?: 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
|
@Service
|
||||||
class IdentityClient(
|
class IdentityClient(
|
||||||
@Value("\${lms.identity.base-url}") baseUrl: String,
|
@Value("\${lms.identity.base-url}") baseUrl: String,
|
||||||
|
@Value("\${lms.internal.token:dev-internal-token}") private val internalToken: String,
|
||||||
) {
|
) {
|
||||||
private val client = restClient(baseUrl)
|
private val client = restClient(baseUrl)
|
||||||
|
|
||||||
@@ -67,11 +129,39 @@ class IdentityClient(
|
|||||||
.retrieve()
|
.retrieve()
|
||||||
.body(type) ?: emptyList()
|
.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-соединение из пула и вешает сервис. */
|
/** Таймауты обязательны: зависший downstream держит JDBC-соединение из пула и вешает сервис. */
|
||||||
private const val CONNECT_TIMEOUT_SECONDS = 3L
|
private const val CONNECT_TIMEOUT_SECONDS = 3L
|
||||||
private const val READ_TIMEOUT_SECONDS = 30L
|
private const val READ_TIMEOUT_SECONDS = 30L
|
||||||
|
private const val INTERNAL_TOKEN_HEADER = "X-Internal-Token"
|
||||||
|
|
||||||
private fun restClient(baseUrl: String): RestClient =
|
private fun restClient(baseUrl: String): RestClient =
|
||||||
RestClient
|
RestClient
|
||||||
|
|||||||
@@ -67,6 +67,45 @@ data class LabInfo(
|
|||||||
val defenseAt: String? = null,
|
val defenseAt: String? = null,
|
||||||
val pointsMax: Any? = null,
|
val pointsMax: Any? = null,
|
||||||
val levels: List<LevelInfo> = emptyList(),
|
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)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ class GradeService(
|
|||||||
private val gradeComments: GradeCommentRepository,
|
private val gradeComments: GradeCommentRepository,
|
||||||
) {
|
) {
|
||||||
@Transactional
|
@Transactional
|
||||||
fun setGrade(request: GradeSetRequest): GradeResponse {
|
fun setGrade(
|
||||||
|
request: GradeSetRequest,
|
||||||
|
authorAccountId: UUID? = null,
|
||||||
|
): GradeResponse {
|
||||||
if (request.score < BigDecimal.ZERO || request.score > BigDecimal("5")) {
|
if (request.score < BigDecimal.ZERO || request.score > BigDecimal("5")) {
|
||||||
throw BadRequestException("Оценка должна быть в диапазоне 0–5")
|
throw BadRequestException("Оценка должна быть в диапазоне 0–5")
|
||||||
}
|
}
|
||||||
@@ -30,9 +33,10 @@ class GradeService(
|
|||||||
val grade = grades.findById(key).orElseGet { LabGrade(studentId = key.studentId, labId = key.labId) }
|
val grade = grades.findById(key).orElseGet { LabGrade(studentId = key.studentId, labId = key.labId) }
|
||||||
grade.score = request.score
|
grade.score = request.score
|
||||||
grade.generalComment = request.generalComment.trim()
|
grade.generalComment = request.generalComment.trim()
|
||||||
|
grade.updatedByAccountId = authorAccountId
|
||||||
grade.updatedAt = java.time.Instant.now()
|
grade.updatedAt = java.time.Instant.now()
|
||||||
grades.save(grade)
|
grades.save(grade)
|
||||||
replaceComments(sid, lid, request.comments)
|
replaceComments(sid, lid, request.comments, authorAccountId)
|
||||||
return GradeResponse(
|
return GradeResponse(
|
||||||
request.studentId,
|
request.studentId,
|
||||||
request.labId,
|
request.labId,
|
||||||
@@ -46,13 +50,22 @@ class GradeService(
|
|||||||
sid: UUID,
|
sid: UUID,
|
||||||
lid: UUID,
|
lid: UUID,
|
||||||
inputs: List<CommentInput>,
|
inputs: List<CommentInput>,
|
||||||
|
authorAccountId: UUID?,
|
||||||
) {
|
) {
|
||||||
val existing = gradeComments.findByStudentIdAndLabId(sid, lid).map { it.text + "|" + it.tone }.toSet()
|
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) }
|
val incoming = inputs.filter { it.text.isNotBlank() }.map { it.text.trim() to normalizeTone(it.tone) }
|
||||||
if (existing.toSet() == incoming.toSet()) return
|
if (existing.toSet() == incoming.toSet()) return
|
||||||
gradeComments.deleteByStudentIdAndLabId(sid, lid)
|
gradeComments.deleteByStudentIdAndLabId(sid, lid)
|
||||||
for ((text, tone) in incoming) {
|
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)
|
val t = templates.findByText(text)
|
||||||
if (t == null) {
|
if (t == null) {
|
||||||
templates.save(CommentTemplate(text = text, tone = tone, useCount = 1))
|
templates.save(CommentTemplate(text = text, tone = tone, useCount = 1))
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ class ProgressController(
|
|||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun setGrade(
|
fun setGrade(
|
||||||
@Valid @RequestBody body: GradeSetRequest,
|
@Valid @RequestBody body: GradeSetRequest,
|
||||||
): GradeResponse = grades.setGrade(body)
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
): GradeResponse = grades.setGrade(body, UUID.fromString(jwt.subject))
|
||||||
|
|
||||||
@GetMapping("/grades")
|
@GetMapping("/grades")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
|
|||||||
@@ -25,5 +25,6 @@ class GradeComment(
|
|||||||
@Column(name = "lab_id", nullable = false) var labId: UUID = UUID(0, 0),
|
@Column(name = "lab_id", nullable = false) var labId: UUID = UUID(0, 0),
|
||||||
@Column(nullable = false) var text: String = "",
|
@Column(nullable = false) var text: String = "",
|
||||||
@Column(nullable = false, length = 8) var tone: String = "neutral",
|
@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(),
|
@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),
|
@Id @Column(nullable = false) var labId: UUID = UUID(0, 0),
|
||||||
@Column(nullable = false) var score: BigDecimal = BigDecimal.ZERO,
|
@Column(nullable = false) var score: BigDecimal = BigDecimal.ZERO,
|
||||||
@Column(name = "general_comment", nullable = false) var generalComment: String = "",
|
@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(),
|
@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,
|
labId: UUID,
|
||||||
): List<GradeComment>
|
): List<GradeComment>
|
||||||
|
|
||||||
|
fun findByStudentIdAndLabIdIn(
|
||||||
|
studentId: UUID,
|
||||||
|
labIds: Collection<UUID>,
|
||||||
|
): List<GradeComment>
|
||||||
|
|
||||||
fun deleteByStudentIdAndLabId(
|
fun deleteByStudentIdAndLabId(
|
||||||
studentId: UUID,
|
studentId: UUID,
|
||||||
labId: UUID,
|
labId: UUID,
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ interface DefenseSessionRepository : JpaRepository<DefenseSession, UUID> {
|
|||||||
labId: UUID,
|
labId: UUID,
|
||||||
): DefenseSession?
|
): DefenseSession?
|
||||||
|
|
||||||
|
fun findByStudentIdAndLabIdIn(
|
||||||
|
studentId: UUID,
|
||||||
|
labIds: Collection<UUID>,
|
||||||
|
): List<DefenseSession>
|
||||||
|
|
||||||
fun findByStudentId(studentId: UUID): List<DefenseSession>
|
fun findByStudentId(studentId: UUID): List<DefenseSession>
|
||||||
|
|
||||||
fun findByStatus(status: String): List<DefenseSession>
|
fun findByStatus(status: String): List<DefenseSession>
|
||||||
|
|||||||
@@ -10,4 +10,9 @@ interface GradePublicationRepository : JpaRepository<GradePublication, GradePubl
|
|||||||
labId: UUID,
|
labId: UUID,
|
||||||
groupId: UUID,
|
groupId: UUID,
|
||||||
): GradePublication?
|
): GradePublication?
|
||||||
|
|
||||||
|
fun findByGroupIdAndLabIdIn(
|
||||||
|
groupId: UUID,
|
||||||
|
labIds: Collection<UUID>,
|
||||||
|
): List<GradePublication>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ interface GradePublicationStudentRepository : JpaRepository<GradePublicationStud
|
|||||||
labId: UUID,
|
labId: UUID,
|
||||||
): List<GradePublicationStudent>
|
): List<GradePublicationStudent>
|
||||||
|
|
||||||
|
fun findByGroupIdAndLabIdIn(
|
||||||
|
groupId: UUID,
|
||||||
|
labIds: Collection<UUID>,
|
||||||
|
): List<GradePublicationStudent>
|
||||||
|
|
||||||
fun deleteByGroupIdAndLabId(
|
fun deleteByGroupIdAndLabId(
|
||||||
groupId: UUID,
|
groupId: UUID,
|
||||||
labId: UUID,
|
labId: UUID,
|
||||||
|
|||||||
@@ -14,4 +14,9 @@ interface LabGradeRepository : JpaRepository<LabGrade, LabGradeKey> {
|
|||||||
studentId: UUID,
|
studentId: UUID,
|
||||||
labId: UUID,
|
labId: UUID,
|
||||||
): LabGrade?
|
): LabGrade?
|
||||||
|
|
||||||
|
fun findByStudentIdAndLabIdIn(
|
||||||
|
studentId: UUID,
|
||||||
|
labIds: Collection<UUID>,
|
||||||
|
): List<LabGrade>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,5 +15,10 @@ interface StudentLabLevelRepository : JpaRepository<StudentLabLevel, StudentLabL
|
|||||||
labId: UUID,
|
labId: UUID,
|
||||||
): StudentLabLevel?
|
): StudentLabLevel?
|
||||||
|
|
||||||
|
fun findByStudentIdAndLabIdIn(
|
||||||
|
studentId: UUID,
|
||||||
|
labIds: Collection<UUID>,
|
||||||
|
): List<StudentLabLevel>
|
||||||
|
|
||||||
fun deleteByLabId(labId: UUID)
|
fun deleteByLabId(labId: UUID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ interface SubmissionRepository : JpaRepository<Submission, UUID> {
|
|||||||
labId: UUID,
|
labId: UUID,
|
||||||
): List<Submission>
|
): List<Submission>
|
||||||
|
|
||||||
|
fun findByStudentIdAndLabIdIn(
|
||||||
|
studentId: UUID,
|
||||||
|
labIds: Collection<UUID>,
|
||||||
|
): List<Submission>
|
||||||
|
|
||||||
fun findByLabIdOrderByCreatedAtDesc(labId: UUID): List<Submission>
|
fun findByLabIdOrderByCreatedAtDesc(labId: UUID): List<Submission>
|
||||||
|
|
||||||
fun findFirstByStudentIdAndLabIdOrderBySeqDesc(
|
fun findFirstByStudentIdAndLabIdOrderBySeqDesc(
|
||||||
|
|||||||
@@ -14,5 +14,10 @@ interface VariantAssignmentRepository : JpaRepository<VariantAssignment, UUID> {
|
|||||||
labId: UUID,
|
labId: UUID,
|
||||||
): VariantAssignment?
|
): VariantAssignment?
|
||||||
|
|
||||||
|
fun findByStudentIdAndLabIdIn(
|
||||||
|
studentId: UUID,
|
||||||
|
labIds: Collection<UUID>,
|
||||||
|
): List<VariantAssignment>
|
||||||
|
|
||||||
fun deleteByLabId(labId: UUID)
|
fun deleteByLabId(labId: UUID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,4 +37,6 @@ lms:
|
|||||||
academic:
|
academic:
|
||||||
base-url: ${LMS_ACADEMIC_URI:http://localhost:8082}
|
base-url: ${LMS_ACADEMIC_URI:http://localhost:8082}
|
||||||
identity:
|
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