Build: enable ktlint+detekt, format sources, add core unit tests
This commit is contained in:
@@ -23,4 +23,4 @@ dependencies {
|
||||
|
||||
springBoot {
|
||||
mainClass.set("ru.ysu.lms.progress.ProgressServiceApplicationKt")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,16 +12,28 @@ class AcademicClient(
|
||||
) {
|
||||
private val client = RestClient.builder().baseUrl(baseUrl).build()
|
||||
|
||||
fun getLab(labId: String, token: String): LabInfo =
|
||||
client.get().uri("/api/v1/labs/{id}", labId)
|
||||
fun getLab(
|
||||
labId: String,
|
||||
token: String,
|
||||
): LabInfo =
|
||||
client
|
||||
.get()
|
||||
.uri("/api/v1/labs/{id}", labId)
|
||||
.header("Authorization", "Bearer $token")
|
||||
.retrieve().body(LabInfo::class.java)
|
||||
.retrieve()
|
||||
.body(LabInfo::class.java)
|
||||
?: throw NotFoundException("Лабораторная не найдена")
|
||||
|
||||
fun getLabSummary(labId: String, token: String): LabInfo =
|
||||
client.get().uri("/api/v1/labs/{id}", labId)
|
||||
fun getLabSummary(
|
||||
labId: String,
|
||||
token: String,
|
||||
): LabInfo =
|
||||
client
|
||||
.get()
|
||||
.uri("/api/v1/labs/{id}", labId)
|
||||
.header("Authorization", "Bearer $token")
|
||||
.retrieve().body(LabInfo::class.java)
|
||||
.retrieve()
|
||||
.body(LabInfo::class.java)
|
||||
?: throw NotFoundException("Лабораторная не найдена")
|
||||
}
|
||||
|
||||
@@ -31,16 +43,25 @@ class IdentityClient(
|
||||
) {
|
||||
private val client = RestClient.builder().baseUrl(baseUrl).build()
|
||||
|
||||
fun getGroup(groupId: String, token: String): GroupInfo =
|
||||
client.get().uri("/api/v1/groups/{id}", groupId)
|
||||
fun getGroup(
|
||||
groupId: String,
|
||||
token: String,
|
||||
): GroupInfo =
|
||||
client
|
||||
.get()
|
||||
.uri("/api/v1/groups/{id}", groupId)
|
||||
.header("Authorization", "Bearer $token")
|
||||
.retrieve().body(GroupInfo::class.java)
|
||||
.retrieve()
|
||||
.body(GroupInfo::class.java)
|
||||
?: throw NotFoundException("Группа не найдена")
|
||||
|
||||
fun listGroups(token: String): List<Map<String, Any>> {
|
||||
val type = object : ParameterizedTypeReference<List<Map<String, Any>>>() {}
|
||||
return client.get().uri("/api/v1/groups")
|
||||
return client
|
||||
.get()
|
||||
.uri("/api/v1/groups")
|
||||
.header("Authorization", "Bearer $token")
|
||||
.retrieve().body(type) ?: emptyList()
|
||||
.retrieve()
|
||||
.body(type) ?: emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
|
||||
data class DefenseAssignPayload(val studentId: String, val labId: String, val assignmentMd: String)
|
||||
data class DefenseAssignPayload(
|
||||
val studentId: String,
|
||||
val labId: String,
|
||||
val assignmentMd: String,
|
||||
)
|
||||
|
||||
data class DefenseAdminDto(
|
||||
val id: String,
|
||||
@@ -22,27 +33,48 @@ data class DefenseAdminDto(
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/defenses")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
class DefenseAdminController(private val service: DefenseService) {
|
||||
|
||||
class DefenseAdminController(
|
||||
private val service: DefenseService,
|
||||
) {
|
||||
@PostMapping("/assign")
|
||||
fun assign(@RequestBody body: DefenseAssignPayload): DefenseDto =
|
||||
service.assign(UUID.fromString(body.studentId), UUID.fromString(body.labId), body.assignmentMd)
|
||||
fun assign(
|
||||
@RequestBody body: DefenseAssignPayload,
|
||||
): DefenseDto = service.assign(UUID.fromString(body.studentId), UUID.fromString(body.labId), body.assignmentMd)
|
||||
|
||||
@DeleteMapping("/assign")
|
||||
fun removeAssignment(@RequestParam studentId: String, @RequestParam labId: String) {
|
||||
fun removeAssignment(
|
||||
@RequestParam studentId: String,
|
||||
@RequestParam labId: String,
|
||||
) {
|
||||
service.resetByStudentLab(UUID.fromString(studentId), UUID.fromString(labId))
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
fun list(@RequestParam labId: UUID): List<DefenseAdminDto> =
|
||||
fun list(
|
||||
@RequestParam labId: UUID,
|
||||
): List<DefenseAdminDto> =
|
||||
service.listForLab(labId).map {
|
||||
DefenseAdminDto(it.id, it.studentId, it.labId, it.status, it.code, it.assignmentMd,
|
||||
it.startedAt, it.readyAt, it.readyReason, it.secondsLeft)
|
||||
DefenseAdminDto(
|
||||
it.id,
|
||||
it.studentId,
|
||||
it.labId,
|
||||
it.status,
|
||||
it.code,
|
||||
it.assignmentMd,
|
||||
it.startedAt,
|
||||
it.readyAt,
|
||||
it.readyReason,
|
||||
it.secondsLeft,
|
||||
)
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/issue-code")
|
||||
fun issueCode(@PathVariable id: UUID): Map<String, String> = mapOf("code" to service.issueCode(id))
|
||||
fun issueCode(
|
||||
@PathVariable id: UUID,
|
||||
): Map<String, String> = mapOf("code" to service.issueCode(id))
|
||||
|
||||
@PostMapping("/{id}/reset")
|
||||
fun reset(@PathVariable id: UUID) = service.reset(id)
|
||||
fun reset(
|
||||
@PathVariable id: UUID,
|
||||
) = service.reset(id)
|
||||
}
|
||||
|
||||
@@ -2,18 +2,27 @@ package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/me/defenses")
|
||||
class DefenseMyController(private val service: DefenseService) {
|
||||
|
||||
data class StartByCodePayload(val code: String)
|
||||
class DefenseMyController(
|
||||
private val service: DefenseService,
|
||||
) {
|
||||
data class StartByCodePayload(
|
||||
val code: String,
|
||||
)
|
||||
|
||||
@GetMapping
|
||||
fun my(@AuthenticationPrincipal jwt: Jwt): List<DefenseDto> =
|
||||
service.mySessions(UUID.fromString(jwt.subject)).map { hideCode(it) }
|
||||
fun my(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<DefenseDto> = service.mySessions(UUID.fromString(jwt.subject)).map { hideCode(it) }
|
||||
|
||||
@GetMapping("/{labId}")
|
||||
fun one(
|
||||
@@ -21,8 +30,7 @@ class DefenseMyController(private val service: DefenseService) {
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): DefenseDto? = service.mySession(UUID.fromString(jwt.subject), labId)?.let { hideCode(it) }
|
||||
|
||||
private fun hideCode(d: DefenseDto): DefenseDto =
|
||||
if (d.status == DefenseStatus.waiting.name) d.copy(code = null) else d
|
||||
private fun hideCode(d: DefenseDto): DefenseDto = if (d.status == DefenseStatus.waiting.name) d.copy(code = null) else d
|
||||
|
||||
@PostMapping("/start")
|
||||
fun start(
|
||||
|
||||
@@ -31,7 +31,6 @@ data class DefenseDto(
|
||||
class DefenseService(
|
||||
private val sessions: DefenseSessionRepository,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
val DURATION: Duration = Duration.ofMinutes(30)
|
||||
private val random = SecureRandom()
|
||||
@@ -55,7 +54,11 @@ class DefenseService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun assign(studentId: UUID, labId: UUID, assignmentMd: String): DefenseDto {
|
||||
fun assign(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
assignmentMd: String,
|
||||
): DefenseDto {
|
||||
val existing = sessions.findByStudentIdAndLabId(studentId, labId)
|
||||
val s = existing ?: DefenseSession(studentId = studentId, labId = labId)
|
||||
// смена задания сбрасывает активную попытку защиты
|
||||
@@ -106,10 +109,14 @@ class DefenseService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun startByCode(studentId: UUID, code: String): DefenseDto {
|
||||
fun startByCode(
|
||||
studentId: UUID,
|
||||
code: String,
|
||||
): DefenseDto {
|
||||
syncTimeouts()
|
||||
val s = sessions.findByCode(code.trim())
|
||||
?: throw BadRequestException("Код не найден")
|
||||
val s =
|
||||
sessions.findByCode(code.trim())
|
||||
?: throw BadRequestException("Код не найден")
|
||||
if (s.studentId != studentId) throw ForbiddenException("Этот код выдан другому студенту")
|
||||
if (s.status != DefenseStatus.waiting.name) throw BadRequestException("Защита по этому коду уже начата")
|
||||
s.status = DefenseStatus.started.name
|
||||
@@ -118,7 +125,10 @@ class DefenseService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun finish(studentId: UUID, sessionId: UUID): DefenseDto {
|
||||
fun finish(
|
||||
studentId: UUID,
|
||||
sessionId: UUID,
|
||||
): DefenseDto {
|
||||
val s = sessions.findById(sessionId).orElseThrow { NotFoundException("Сессия не найдена") }
|
||||
if (s.studentId != studentId) throw ForbiddenException("Не ваша сессия")
|
||||
if (s.status != DefenseStatus.started.name) throw BadRequestException("Защита ещё не начата")
|
||||
@@ -135,13 +145,19 @@ class DefenseService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun mySession(studentId: UUID, labId: UUID): DefenseDto? {
|
||||
fun mySession(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): DefenseDto? {
|
||||
syncTimeouts()
|
||||
return sessions.findByStudentIdAndLabId(studentId, labId)?.let { toDto(it) }
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun resetByStudentLab(studentId: UUID, labId: UUID) {
|
||||
fun resetByStudentLab(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
) {
|
||||
val s = sessions.findByStudentIdAndLabId(studentId, labId) ?: return
|
||||
s.status = DefenseStatus.waiting.name
|
||||
s.code = null
|
||||
@@ -152,11 +168,18 @@ class DefenseService(
|
||||
}
|
||||
|
||||
private fun toDto(s: DefenseSession): DefenseDto {
|
||||
val left = if (s.status == DefenseStatus.started.name) {
|
||||
val started = s.startedAt ?: Instant.now()
|
||||
val remain = java.time.Duration.between(Instant.now(), started.plus(DURATION)).seconds.coerceAtLeast(0)
|
||||
remain
|
||||
} else null
|
||||
val left =
|
||||
if (s.status == DefenseStatus.started.name) {
|
||||
val started = s.startedAt ?: Instant.now()
|
||||
val remain =
|
||||
java.time.Duration
|
||||
.between(Instant.now(), started.plus(DURATION))
|
||||
.seconds
|
||||
.coerceAtLeast(0)
|
||||
remain
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return DefenseDto(
|
||||
id = s.id.toString(),
|
||||
studentId = s.studentId.toString(),
|
||||
|
||||
@@ -136,8 +136,15 @@ data class GradeSetRequest(
|
||||
val comments: List<CommentInput> = emptyList(),
|
||||
)
|
||||
|
||||
data class CommentInput(val text: String, val tone: String = "neutral")
|
||||
data class CommentOut(val text: String, val tone: String)
|
||||
data class CommentInput(
|
||||
val text: String,
|
||||
val tone: String = "neutral",
|
||||
)
|
||||
|
||||
data class CommentOut(
|
||||
val text: String,
|
||||
val tone: String,
|
||||
)
|
||||
|
||||
data class GradeResponse(
|
||||
val studentId: String,
|
||||
@@ -153,4 +160,4 @@ data class SubmissionDto(
|
||||
val labId: String,
|
||||
val createdAt: String,
|
||||
val files: List<String>,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3,29 +3,33 @@ package ru.ysu.lms.progress
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.progress.domain.GradePublication
|
||||
import ru.ysu.lms.progress.domain.GradePublicationKey
|
||||
import ru.ysu.lms.progress.repo.GradePublicationRepository
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class GradePublicationService(private val repo: GradePublicationRepository) {
|
||||
|
||||
class GradePublicationService(
|
||||
private val repo: GradePublicationRepository,
|
||||
) {
|
||||
@Transactional
|
||||
fun setPublished(groupId: UUID, labId: UUID, published: Boolean) {
|
||||
fun setPublished(
|
||||
groupId: UUID,
|
||||
labId: UUID,
|
||||
published: Boolean,
|
||||
) {
|
||||
val existing = repo.findByLabIdAndGroupId(labId, groupId)
|
||||
if (published && existing == null) repo.save(GradePublication(groupId, labId))
|
||||
if (!published && existing != null) repo.delete(existing)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun isPublished(groupId: UUID, labId: UUID): Boolean =
|
||||
repo.findByLabIdAndGroupId(labId, groupId) != null
|
||||
fun isPublished(
|
||||
groupId: UUID,
|
||||
labId: UUID,
|
||||
): Boolean = repo.findByLabIdAndGroupId(labId, groupId) != null
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun publishedGroupIdsForLab(labId: UUID): List<String> =
|
||||
repo.findByLabId(labId).map { it.groupId.toString() }
|
||||
fun publishedGroupIdsForLab(labId: UUID): List<String> = repo.findByLabId(labId).map { it.groupId.toString() }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun publishedLabIdsForGroup(groupId: UUID): List<String> =
|
||||
repo.findByGroupId(groupId).map { it.labId.toString() }
|
||||
fun publishedLabIdsForGroup(groupId: UUID): List<String> = repo.findByGroupId(groupId).map { it.labId.toString() }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,14 @@ 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.*
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RequestPart
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
@@ -21,9 +28,10 @@ class ProgressController(
|
||||
private val submissions: SubmissionService,
|
||||
private val academic: AcademicClient,
|
||||
) {
|
||||
|
||||
@GetMapping("/me/assignments")
|
||||
fun meAssignments(@AuthenticationPrincipal jwt: Jwt): List<MeAssignmentResponse> {
|
||||
fun meAssignments(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<MeAssignmentResponse> {
|
||||
val sid = UUID.fromString(jwt.subject)
|
||||
return service.getStudentAssignments(sid).mapNotNull { va ->
|
||||
service.getStudentAssignment(sid, va.labId, jwt.tokenValue)
|
||||
@@ -31,9 +39,9 @@ class ProgressController(
|
||||
}
|
||||
|
||||
@GetMapping("/me/labs")
|
||||
fun meLabs(@AuthenticationPrincipal jwt: Jwt): List<Map<String, Any>> {
|
||||
return listOf()
|
||||
}
|
||||
fun meLabs(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<Map<String, Any>> = listOf()
|
||||
|
||||
@PostMapping("/levels")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@@ -44,7 +52,9 @@ class ProgressController(
|
||||
|
||||
@GetMapping("/levels")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun getLevels(@RequestParam labId: UUID): List<LevelAssignmentResponse> =
|
||||
fun getLevels(
|
||||
@RequestParam labId: UUID,
|
||||
): List<LevelAssignmentResponse> =
|
||||
service.getLevelsForLab(labId).map { sll ->
|
||||
LevelAssignmentResponse(
|
||||
studentId = sll.studentId.toString(),
|
||||
@@ -77,7 +87,9 @@ class ProgressController(
|
||||
|
||||
@PostMapping("/grades")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun setGrade(@Valid @RequestBody body: GradeSetRequest): GradeResponse = service.setGrade(body)
|
||||
fun setGrade(
|
||||
@Valid @RequestBody body: GradeSetRequest,
|
||||
): GradeResponse = service.setGrade(body)
|
||||
|
||||
@GetMapping("/grades")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@@ -85,19 +97,26 @@ class ProgressController(
|
||||
|
||||
@PostMapping("/grades/publish")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun publishGrades(@RequestParam labId: java.util.UUID, @RequestParam groupId: java.util.UUID, @RequestParam published: Boolean) {
|
||||
fun publishGrades(
|
||||
@RequestParam labId: java.util.UUID,
|
||||
@RequestParam groupId: java.util.UUID,
|
||||
@RequestParam published: Boolean,
|
||||
) {
|
||||
publications.setPublished(groupId, labId, published)
|
||||
}
|
||||
|
||||
@GetMapping("/grades/published")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun gradesPublished(@RequestParam("labId") labId: java.util.UUID, @RequestParam groupId: java.util.UUID): Boolean =
|
||||
publications.isPublished(groupId, labId)
|
||||
fun gradesPublished(
|
||||
@RequestParam("labId") labId: java.util.UUID,
|
||||
@RequestParam groupId: java.util.UUID,
|
||||
): Boolean = publications.isPublished(groupId, labId)
|
||||
|
||||
@GetMapping("/grades/publications")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
fun groupPublications(@RequestParam groupId: java.util.UUID): List<String> =
|
||||
publications.publishedLabIdsForGroup(groupId)
|
||||
fun groupPublications(
|
||||
@RequestParam groupId: java.util.UUID,
|
||||
): List<String> = publications.publishedLabIdsForGroup(groupId)
|
||||
|
||||
@GetMapping("/me/grades")
|
||||
fun myGrades(
|
||||
@@ -111,7 +130,9 @@ class ProgressController(
|
||||
|
||||
@GetMapping("/comment-templates")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun suggestComments(@RequestParam q: String): List<CommentOut> = service.suggestComments(q)
|
||||
fun suggestComments(
|
||||
@RequestParam q: String,
|
||||
): List<CommentOut> = service.suggestComments(q)
|
||||
|
||||
@PostMapping("/me/submissions/{labId}")
|
||||
fun submit(
|
||||
@@ -131,7 +152,10 @@ class ProgressController(
|
||||
): List<SubmissionDto> = submissions.mySubmissions(UUID.fromString(jwt.subject), labId)
|
||||
|
||||
@GetMapping("/me/submissions/{id}/download")
|
||||
fun downloadMine(@PathVariable id: UUID, @AuthenticationPrincipal jwt: Jwt): ResponseEntity<ByteArray> {
|
||||
fun downloadMine(
|
||||
@PathVariable id: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): ResponseEntity<ByteArray> {
|
||||
val sid = UUID.fromString(jwt.subject)
|
||||
val (bytes, name) = submissions.download(sid, id, sid, isAdmin = false)
|
||||
return attachment(bytes, name)
|
||||
@@ -139,22 +163,29 @@ class ProgressController(
|
||||
|
||||
@GetMapping("/submissions")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun labSubmissions(@RequestParam labId: UUID): List<SubmissionDto> =
|
||||
submissions.latestPerStudent(labId)
|
||||
fun labSubmissions(
|
||||
@RequestParam labId: UUID,
|
||||
): List<SubmissionDto> = submissions.latestPerStudent(labId)
|
||||
|
||||
@GetMapping("/submissions/{id}/download")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun downloadForAdmin(@PathVariable id: UUID, @AuthenticationPrincipal jwt: Jwt): ResponseEntity<ByteArray> {
|
||||
fun downloadForAdmin(
|
||||
@PathVariable id: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): ResponseEntity<ByteArray> {
|
||||
val (bytes, name) = submissions.download(UUID.fromString(jwt.subject), id, null, isAdmin = true)
|
||||
return attachment(bytes, name)
|
||||
}
|
||||
|
||||
private fun attachment(bytes: ByteArray, name: String): ResponseEntity<ByteArray> =
|
||||
ResponseEntity.ok()
|
||||
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)
|
||||
).contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.body(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@ import ru.ysu.lms.progress.domain.GradeComment
|
||||
import ru.ysu.lms.progress.domain.LabGrade
|
||||
import ru.ysu.lms.progress.domain.LabGradeKey
|
||||
import ru.ysu.lms.progress.domain.StudentLabLevel
|
||||
import ru.ysu.lms.progress.domain.StudentLabLevelKey
|
||||
import ru.ysu.lms.progress.domain.VariantAssignment
|
||||
import ru.ysu.lms.progress.repo.CommentTemplateRepository
|
||||
import ru.ysu.lms.progress.repo.GradeCommentRepository
|
||||
import ru.ysu.lms.progress.repo.LabGradeRepository
|
||||
import ru.ysu.lms.progress.repo.StudentLabLevelRepository
|
||||
import ru.ysu.lms.progress.repo.VariantAssignmentRepository
|
||||
import ru.ysu.lms.progress.domain.StudentLabLevelKey
|
||||
import java.math.BigDecimal
|
||||
import java.util.UUID
|
||||
|
||||
@@ -28,9 +28,11 @@ class ProgressService(
|
||||
private val academic: AcademicClient,
|
||||
private val identity: IdentityClient,
|
||||
) {
|
||||
|
||||
@Transactional
|
||||
fun setLevel(request: LevelAssignmentRequest, token: String): LevelAssignmentResponse {
|
||||
fun setLevel(
|
||||
request: LevelAssignmentRequest,
|
||||
token: String,
|
||||
): LevelAssignmentResponse {
|
||||
val sid = UUID.fromString(request.studentId)
|
||||
val lid = UUID.fromString(request.labId)
|
||||
val key = StudentLabLevelKey(sid, lid)
|
||||
@@ -46,20 +48,27 @@ class ProgressService(
|
||||
val level = lab?.levels?.firstOrNull { it.id == request.labLevelId }
|
||||
if (level != null && level.variants.isNotEmpty()) {
|
||||
val assignment = assignments.findByStudentIdAndLabId(sid, lid)
|
||||
val currentInLevel = assignment?.variantId?.toString()
|
||||
?.let { vid -> level.variants.any { it.id == vid } } == true
|
||||
val currentInLevel =
|
||||
assignment
|
||||
?.variantId
|
||||
?.toString()
|
||||
?.let { vid -> level.variants.any { it.id == vid } } == true
|
||||
if (!currentInLevel) {
|
||||
val used = assignments.findByLabId(lid)
|
||||
.map { it.variantId.toString() }
|
||||
.filter { it != assignment?.variantId?.toString() }
|
||||
.toMutableSet()
|
||||
val used =
|
||||
assignments
|
||||
.findByLabId(lid)
|
||||
.map { it.variantId.toString() }
|
||||
.filter { it != assignment?.variantId?.toString() }
|
||||
.toMutableSet()
|
||||
val pool = level.variants.map { it.id }
|
||||
val chosen = pool.filter { it !in used }.shuffled().firstOrNull()
|
||||
?: pool.shuffled().first()
|
||||
val target = (assignment ?: VariantAssignment(studentId = sid, labId = lid)).apply {
|
||||
variantId = UUID.fromString(chosen)
|
||||
manual = false
|
||||
}
|
||||
val chosen =
|
||||
pool.filter { it !in used }.shuffled().firstOrNull()
|
||||
?: pool.shuffled().first()
|
||||
val target =
|
||||
(assignment ?: VariantAssignment(studentId = sid, labId = lid)).apply {
|
||||
variantId = UUID.fromString(chosen)
|
||||
manual = false
|
||||
}
|
||||
assignments.save(target)
|
||||
}
|
||||
}
|
||||
@@ -72,23 +81,34 @@ class ProgressService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getLevel(studentId: UUID, labId: UUID): StudentLabLevel? =
|
||||
levels.findByStudentIdAndLabId(studentId, labId)
|
||||
fun getLevel(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): StudentLabLevel? = levels.findByStudentIdAndLabId(studentId, labId)
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getLevelsForLab(labId: UUID): List<StudentLabLevel> =
|
||||
levels.findByLabId(labId)
|
||||
fun getLevelsForLab(labId: UUID): List<StudentLabLevel> = levels.findByLabId(labId)
|
||||
|
||||
@Transactional
|
||||
fun distribute(request: DistributeRequest, token: String): DistributeResult {
|
||||
val labIds = request.labIds.orEmpty().ifEmpty { listOfNotNull(request.labId) }
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
fun distribute(
|
||||
request: DistributeRequest,
|
||||
token: String,
|
||||
): DistributeResult {
|
||||
val labIds =
|
||||
request.labIds
|
||||
.orEmpty()
|
||||
.ifEmpty { listOfNotNull(request.labId) }
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
if (labIds.isEmpty()) throw BadRequestException("Не указана ни одна лабораторная")
|
||||
|
||||
val force = request.force == true
|
||||
val groupFilter = request.groupIds.orEmpty().filter { it.isNotBlank() }.toSet()
|
||||
val groupFilter =
|
||||
request.groupIds
|
||||
.orEmpty()
|
||||
.filter { it.isNotBlank() }
|
||||
.toSet()
|
||||
val studentIds = mutableListOf<UUID>()
|
||||
for (g in identity.listGroups(token)) {
|
||||
val gid = g["id"] as String
|
||||
@@ -111,14 +131,16 @@ class ProgressService(
|
||||
// force сбрасывает авто-назначения только у выбранных студентов;
|
||||
// закреплённые вручную варианты остаются как есть.
|
||||
if (force) {
|
||||
assignments.findByLabId(labId)
|
||||
assignments
|
||||
.findByLabId(labId)
|
||||
.filter { it.studentId in allStudents && !it.manual }
|
||||
.forEach { assignments.delete(it) }
|
||||
}
|
||||
|
||||
val kept = mutableMapOf<UUID, VariantAssignment>()
|
||||
assignedTotal += assignments.findByLabId(labId).count { it.studentId in allStudents }
|
||||
assignments.findByLabId(labId)
|
||||
assignments
|
||||
.findByLabId(labId)
|
||||
.filter { it.studentId in allStudents }
|
||||
.forEach { kept[it.studentId] = it }
|
||||
|
||||
@@ -132,17 +154,23 @@ class ProgressService(
|
||||
val defaultLevel = lab.levels.firstOrNull()?.id
|
||||
|
||||
if (toAssign.isNotEmpty() && poolsByLevel.isNotEmpty()) {
|
||||
val usage = assignments.findByLabId(labId)
|
||||
.groupingBy { it.variantId.toString() }
|
||||
.eachCount()
|
||||
.toMutableMap()
|
||||
val usage =
|
||||
assignments
|
||||
.findByLabId(labId)
|
||||
.groupingBy { it.variantId.toString() }
|
||||
.eachCount()
|
||||
.toMutableMap()
|
||||
|
||||
for (sid in toAssign.shuffled()) {
|
||||
val levelId = levels.findByStudentIdAndLabId(sid, labId)?.labLevelId?.toString()
|
||||
?: defaultLevel
|
||||
val pool = levelId?.let { poolsByLevel[it] }.orEmpty()
|
||||
.ifEmpty { defaultLevel?.let { poolsByLevel[it] }.orEmpty() }
|
||||
.ifEmpty { poolsByLevel.values.firstOrNull { it.isNotEmpty() }.orEmpty() }
|
||||
val levelId =
|
||||
levels.findByStudentIdAndLabId(sid, labId)?.labLevelId?.toString()
|
||||
?: defaultLevel
|
||||
val pool =
|
||||
levelId
|
||||
?.let { poolsByLevel[it] }
|
||||
.orEmpty()
|
||||
.ifEmpty { defaultLevel?.let { poolsByLevel[it] }.orEmpty() }
|
||||
.ifEmpty { poolsByLevel.values.firstOrNull { it.isNotEmpty() }.orEmpty() }
|
||||
if (pool.isEmpty()) continue
|
||||
val minCount = pool.minOf { usage[it] ?: 0 }
|
||||
val chosen = pool.filter { (usage[it] ?: 0) == minCount }.random()
|
||||
@@ -174,23 +202,26 @@ class ProgressService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> =
|
||||
assignments.findByLabId(labId)
|
||||
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> = assignments.findByLabId(labId)
|
||||
|
||||
@Transactional
|
||||
fun assign(request: AssignRequest): VariantAssignment {
|
||||
val sid = UUID.fromString(request.studentId)
|
||||
val lid = UUID.fromString(request.labId)
|
||||
val existing = assignments.findByStudentIdAndLabId(sid, lid)
|
||||
val entity = (existing ?: VariantAssignment(studentId = sid, labId = lid)).apply {
|
||||
variantId = UUID.fromString(request.variantId)
|
||||
manual = true
|
||||
}
|
||||
val entity =
|
||||
(existing ?: VariantAssignment(studentId = sid, labId = lid)).apply {
|
||||
variantId = UUID.fromString(request.variantId)
|
||||
manual = true
|
||||
}
|
||||
return assignments.save(entity)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun assignWithInfo(request: AssignRequest, token: String): VariantAssignmentResponse {
|
||||
fun assignWithInfo(
|
||||
request: AssignRequest,
|
||||
token: String,
|
||||
): VariantAssignmentResponse {
|
||||
assign(request)
|
||||
val lab = academic.getLab(request.labId, token)
|
||||
val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == request.variantId }
|
||||
@@ -204,7 +235,10 @@ class ProgressService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun assignmentsForLabEnriched(labId: UUID, token: String): List<VariantAssignmentResponse> {
|
||||
fun assignmentsForLabEnriched(
|
||||
labId: UUID,
|
||||
token: String,
|
||||
): List<VariantAssignmentResponse> {
|
||||
val lab = academic.getLab(labId.toString(), token)
|
||||
val variants = lab.levels.flatMap { it.variants }.associateBy { it.id }
|
||||
return assignments.findByLabId(labId).map { va ->
|
||||
@@ -220,8 +254,7 @@ class ProgressService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> =
|
||||
assignments.findByStudentId(studentId)
|
||||
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> = assignments.findByStudentId(studentId)
|
||||
|
||||
@Transactional
|
||||
fun setGrade(request: GradeSetRequest): GradeResponse {
|
||||
@@ -235,11 +268,19 @@ class ProgressService(
|
||||
grade.score = request.score
|
||||
grades.save(grade)
|
||||
replaceComments(sid, lid, request.comments)
|
||||
return GradeResponse(request.studentId, request.labId, request.score,
|
||||
commentsOf(sid, lid).map { CommentOut(it.text, it.tone) })
|
||||
return GradeResponse(
|
||||
request.studentId,
|
||||
request.labId,
|
||||
request.score,
|
||||
commentsOf(sid, lid).map { CommentOut(it.text, it.tone) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun replaceComments(sid: UUID, lid: UUID, inputs: List<CommentInput>) {
|
||||
private fun replaceComments(
|
||||
sid: UUID,
|
||||
lid: UUID,
|
||||
inputs: List<CommentInput>,
|
||||
) {
|
||||
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
|
||||
@@ -247,23 +288,33 @@ class ProgressService(
|
||||
for ((text, tone) in incoming) {
|
||||
gradeComments.save(GradeComment(studentId = sid, labId = lid, text = text, tone = tone))
|
||||
val t = templates.findByText(text)
|
||||
if (t == null) templates.save(CommentTemplate(text = text, tone = tone, useCount = 1))
|
||||
else { t.useCount += 1; t.tone = tone; templates.save(t) }
|
||||
if (t == null) {
|
||||
templates.save(CommentTemplate(text = text, tone = tone, useCount = 1))
|
||||
} else {
|
||||
t.useCount += 1
|
||||
t.tone = tone
|
||||
templates.save(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeTone(tone: String): String = when (tone.lowercase()) {
|
||||
"positive", "положительный", "позитивный", "pos" -> "positive"
|
||||
"negative", "негативный", "отрицательный", "neg" -> "negative"
|
||||
else -> "neutral"
|
||||
}
|
||||
private fun normalizeTone(tone: String): String =
|
||||
when (tone.lowercase()) {
|
||||
"positive", "положительный", "позитивный", "pos" -> "positive"
|
||||
"negative", "негативный", "отрицательный", "neg" -> "negative"
|
||||
else -> "neutral"
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun commentsOf(sid: UUID, lid: UUID): List<GradeComment> = gradeComments.findByStudentIdAndLabId(sid, lid)
|
||||
fun commentsOf(
|
||||
sid: UUID,
|
||||
lid: UUID,
|
||||
): List<GradeComment> = gradeComments.findByStudentIdAndLabId(sid, lid)
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun suggestComments(query: String): List<CommentOut> =
|
||||
templates.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim())
|
||||
templates
|
||||
.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim())
|
||||
.map { CommentOut(it.text, it.tone) }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -271,8 +322,10 @@ class ProgressService(
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun listGrades(): List<GradeResponse> {
|
||||
val byGrade = gradeComments.findAll()
|
||||
.groupBy { it.studentId to it.labId }
|
||||
val byGrade =
|
||||
gradeComments
|
||||
.findAll()
|
||||
.groupBy { it.studentId to it.labId }
|
||||
return grades.findAllBy().map {
|
||||
GradeResponse(
|
||||
studentId = it.studentId.toString(),
|
||||
@@ -284,12 +337,19 @@ class ProgressService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getStudentAssignment(studentId: UUID, labId: UUID, token: String): MeAssignmentResponse? {
|
||||
fun getStudentAssignment(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
token: String,
|
||||
): MeAssignmentResponse? {
|
||||
val va = assignments.findByStudentIdAndLabId(studentId, labId) ?: return null
|
||||
return enrich(va, token)
|
||||
}
|
||||
|
||||
private fun enrich(va: VariantAssignment, token: String): MeAssignmentResponse? {
|
||||
private fun enrich(
|
||||
va: VariantAssignment,
|
||||
token: String,
|
||||
): MeAssignmentResponse? {
|
||||
val lab = runCatching { academic.getLab(va.labId.toString(), token) }.getOrNull() ?: return null
|
||||
val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == va.variantId.toString() }
|
||||
val level = lab.levels.firstOrNull { lv -> lv.variants.any { it.id == va.variantId.toString() } }
|
||||
@@ -306,4 +366,4 @@ class ProgressService(
|
||||
pointsMax = lab.pointsMax,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@ package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import ru.ysu.lms.security.LmsSecurityAutoConfiguration
|
||||
|
||||
@SpringBootApplication
|
||||
class ProgressServiceApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<ProgressServiceApplication>(*args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ class SubmissionService(
|
||||
private val storage: SubmissionStorage,
|
||||
private val academic: AcademicClient,
|
||||
) {
|
||||
|
||||
@Transactional
|
||||
fun submit(
|
||||
studentId: UUID,
|
||||
@@ -29,8 +28,9 @@ class SubmissionService(
|
||||
if (files.isEmpty() || files.all { it.isEmpty }) {
|
||||
throw BadRequestException("Приложите файлы или архив")
|
||||
}
|
||||
val lab = runCatching { academic.getLabSummary(labId.toString(), token) }.getOrNull()
|
||||
?: throw NotFoundException("Лабораторная не найдена")
|
||||
val lab =
|
||||
runCatching { academic.getLabSummary(labId.toString(), token) }.getOrNull()
|
||||
?: throw NotFoundException("Лабораторная не найдена")
|
||||
val deadline = lab.deadlineAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
||||
if (deadline != null && Instant.now().isAfter(deadline)) {
|
||||
throw BadRequestException("Срок сдачи истёк")
|
||||
@@ -52,12 +52,22 @@ class SubmissionService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun mySubmissions(studentId: UUID, labId: UUID): List<SubmissionDto> =
|
||||
submissions.findByStudentIdAndLabIdOrderBySeqDesc(studentId, labId).map { it to filesOf(it) }
|
||||
fun mySubmissions(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): List<SubmissionDto> =
|
||||
submissions
|
||||
.findByStudentIdAndLabIdOrderBySeqDesc(studentId, labId)
|
||||
.map { it to filesOf(it) }
|
||||
.map { (e, fs) -> toDto(e, fs) }
|
||||
|
||||
@Transactional
|
||||
fun download(studentId: UUID, submissionId: UUID, callerStudentId: UUID?, isAdmin: Boolean): Pair<ByteArray, String> {
|
||||
fun download(
|
||||
studentId: UUID,
|
||||
submissionId: UUID,
|
||||
callerStudentId: UUID?,
|
||||
isAdmin: Boolean,
|
||||
): Pair<ByteArray, String> {
|
||||
val s = submissions.findById(submissionId).orElseThrow { NotFoundException("Сдача не найдена") }
|
||||
if (!isAdmin && (callerStudentId == null || s.studentId != callerStudentId)) {
|
||||
throw ForbiddenException("Нет доступа к этой сдаче")
|
||||
@@ -81,23 +91,29 @@ class SubmissionService(
|
||||
/** Файлы с "исходниковым" расширением, содержимое которых — XML-файл проекта (VS/IDE). */
|
||||
private val sourceExtensions = setOf("c", "h", "cpp", "hpp", "cc", "cxx", "txt")
|
||||
|
||||
private fun looksLikeProjectFile(name: String, bytes: ByteArray): Boolean {
|
||||
private fun looksLikeProjectFile(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
): Boolean {
|
||||
val ext = name.substringAfterLast('.', "").lowercase()
|
||||
if (ext !in sourceExtensions) return false
|
||||
val head = String(bytes, 0, minOf(bytes.size, 512), Charsets.UTF_8)
|
||||
.removePrefix("\uFEFF")
|
||||
.trimStart()
|
||||
.lowercase()
|
||||
val head =
|
||||
String(bytes, 0, minOf(bytes.size, 512), Charsets.UTF_8)
|
||||
.removePrefix("\uFEFF")
|
||||
.trimStart()
|
||||
.lowercase()
|
||||
return head.startsWith("<?xml") ||
|
||||
head.startsWith("<solution") ||
|
||||
head.startsWith("<project") ||
|
||||
head.startsWith("<configurations")
|
||||
}
|
||||
|
||||
private fun filesOf(s: Submission): List<String> =
|
||||
storage.listFiles(storage.dirFor(s.studentId, s.labId, s.seq))
|
||||
private fun filesOf(s: Submission): List<String> = storage.listFiles(storage.dirFor(s.studentId, s.labId, s.seq))
|
||||
|
||||
private fun toDto(s: Submission, files: List<String>): SubmissionDto =
|
||||
private fun toDto(
|
||||
s: Submission,
|
||||
files: List<String>,
|
||||
): SubmissionDto =
|
||||
SubmissionDto(
|
||||
id = s.id.toString(),
|
||||
seq = s.seq,
|
||||
|
||||
@@ -9,14 +9,29 @@ import java.util.UUID
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
data class StoredSubmission(val dir: Path, val files: List<String>)
|
||||
data class StoredSubmission(
|
||||
val dir: Path,
|
||||
val files: List<String>,
|
||||
)
|
||||
|
||||
@Component
|
||||
class SubmissionStorage(@Value("\${lms.submissions.dir:data/submissions}") root: String) {
|
||||
class SubmissionStorage(
|
||||
@Value("\${lms.submissions.dir:data/submissions}") root: String,
|
||||
) {
|
||||
private val rootDir: Path = Path.of(root)
|
||||
|
||||
fun store(studentId: UUID, labId: UUID, seq: Int, files: List<Pair<String, ByteArray>>): StoredSubmission {
|
||||
val dir = rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString()).normalize()
|
||||
fun store(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
seq: Int,
|
||||
files: List<Pair<String, ByteArray>>,
|
||||
): StoredSubmission {
|
||||
val dir =
|
||||
rootDir
|
||||
.resolve(studentId.toString())
|
||||
.resolve(labId.toString())
|
||||
.resolve(seq.toString())
|
||||
.normalize()
|
||||
require(dir.startsWith(rootDir)) { "path escape blocked" }
|
||||
Files.createDirectories(dir)
|
||||
val names = mutableListOf<String>()
|
||||
@@ -46,13 +61,20 @@ class SubmissionStorage(@Value("\${lms.submissions.dir:data/submissions}") root:
|
||||
|
||||
fun listFiles(storedDir: Path): List<String> =
|
||||
if (Files.isDirectory(storedDir)) {
|
||||
Files.list(storedDir).map { it.fileName.toString() }.sorted().toList()
|
||||
Files
|
||||
.list(storedDir)
|
||||
.map { it.fileName.toString() }
|
||||
.sorted()
|
||||
.toList()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
fun dirFor(studentId: UUID, labId: UUID, seq: Int): Path =
|
||||
rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString())
|
||||
fun dirFor(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
seq: Int,
|
||||
): Path = rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString())
|
||||
|
||||
private fun sanitize(name: String): String {
|
||||
val base = name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "file" }
|
||||
|
||||
@@ -20,4 +20,4 @@ class DefenseSession(
|
||||
@Column(name = "started_at") var startedAt: Instant? = null,
|
||||
@Column(name = "ready_at") var readyAt: Instant? = null,
|
||||
@Column(name = "ready_reason") var readyReason: String? = null,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -23,4 +23,4 @@ class LabGrade(
|
||||
data class LabGradeKey(
|
||||
val studentId: UUID = UUID(0, 0),
|
||||
val labId: UUID = UUID(0, 0),
|
||||
) : Serializable
|
||||
) : Serializable
|
||||
|
||||
@@ -11,13 +11,13 @@ import java.util.UUID
|
||||
@Table(name = "student_lab_levels")
|
||||
@IdClass(StudentLabLevelKey::class)
|
||||
class StudentLabLevel(
|
||||
@Id @Column(nullable = false) var studentId: UUID = UUID(0,0),
|
||||
@Id @Column(nullable = false) var labId: UUID = UUID(0,0),
|
||||
@Column(nullable = false) var labLevelId: UUID = UUID(0,0),
|
||||
@Id @Column(nullable = false) var studentId: UUID = UUID(0, 0),
|
||||
@Id @Column(nullable = false) var labId: UUID = UUID(0, 0),
|
||||
@Column(nullable = false) var labLevelId: UUID = UUID(0, 0),
|
||||
@Column(nullable = false) var manual: Boolean = false,
|
||||
)
|
||||
|
||||
data class StudentLabLevelKey(
|
||||
val studentId: UUID = UUID(0,0),
|
||||
val labId: UUID = UUID(0,0),
|
||||
) : java.io.Serializable
|
||||
val studentId: UUID = UUID(0, 0),
|
||||
val labId: UUID = UUID(0, 0),
|
||||
) : java.io.Serializable
|
||||
|
||||
@@ -16,4 +16,4 @@ class Submission(
|
||||
@Column(nullable = false) var labId: UUID = UUID(0, 0),
|
||||
@Column(nullable = false) var seq: Int = 1,
|
||||
@Column(nullable = false) var createdAt: Instant = Instant.now(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -12,9 +12,9 @@ import java.util.UUID
|
||||
@Table(name = "variant_assignments")
|
||||
class VariantAssignment(
|
||||
@Id var id: UUID = Ids.v7(),
|
||||
@Column(nullable = false) var studentId: UUID = UUID(0,0),
|
||||
@Column(nullable = false) var labId: UUID = UUID(0,0),
|
||||
@Column(nullable = false) var variantId: UUID = UUID(0,0),
|
||||
@Column(nullable = false) var studentId: UUID = UUID(0, 0),
|
||||
@Column(nullable = false) var labId: UUID = UUID(0, 0),
|
||||
@Column(nullable = false) var variantId: UUID = UUID(0, 0),
|
||||
@Column(nullable = false) var manual: Boolean = false,
|
||||
@Column(nullable = false) var assignedAt: Instant = Instant.now(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -7,11 +7,20 @@ import java.util.UUID
|
||||
|
||||
interface CommentTemplateRepository : JpaRepository<CommentTemplate, UUID> {
|
||||
fun findByText(text: String): CommentTemplate?
|
||||
|
||||
fun findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(text: String): List<CommentTemplate>
|
||||
}
|
||||
|
||||
interface GradeCommentRepository : JpaRepository<GradeComment, UUID> {
|
||||
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): List<GradeComment>
|
||||
fun deleteByStudentIdAndLabId(studentId: UUID, labId: UUID)
|
||||
fun findByStudentIdAndLabId(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): List<GradeComment>
|
||||
|
||||
fun deleteByStudentIdAndLabId(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
)
|
||||
|
||||
fun findByStudentId(studentId: UUID): List<GradeComment>
|
||||
}
|
||||
|
||||
@@ -6,8 +6,15 @@ import java.util.UUID
|
||||
|
||||
interface DefenseSessionRepository : JpaRepository<DefenseSession, UUID> {
|
||||
fun findByLabIdOrderByStudentIdAsc(labId: UUID): List<DefenseSession>
|
||||
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): DefenseSession?
|
||||
|
||||
fun findByStudentIdAndLabId(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): DefenseSession?
|
||||
|
||||
fun findByStudentId(studentId: UUID): List<DefenseSession>
|
||||
|
||||
fun findByStatus(status: String): List<DefenseSession>
|
||||
|
||||
fun findByCode(code: String): DefenseSession?
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@ import ru.ysu.lms.progress.domain.GradePublicationKey
|
||||
import java.util.UUID
|
||||
|
||||
interface GradePublicationRepository : JpaRepository<GradePublication, GradePublicationKey> {
|
||||
fun findByLabIdAndGroupId(labId: UUID, groupId: UUID): GradePublication?
|
||||
fun findByLabIdAndGroupId(
|
||||
labId: UUID,
|
||||
groupId: UUID,
|
||||
): GradePublication?
|
||||
|
||||
fun findByLabId(labId: UUID): List<GradePublication>
|
||||
|
||||
fun findByGroupId(groupId: UUID): List<GradePublication>
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ import java.util.UUID
|
||||
|
||||
interface LabGradeRepository : JpaRepository<LabGrade, LabGradeKey> {
|
||||
fun findAllBy(): List<LabGrade>
|
||||
|
||||
fun findByStudentId(studentId: UUID): List<LabGrade>
|
||||
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): LabGrade?
|
||||
|
||||
fun findByStudentIdAndLabId(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): LabGrade?
|
||||
}
|
||||
|
||||
@@ -7,7 +7,13 @@ import java.util.UUID
|
||||
|
||||
interface StudentLabLevelRepository : JpaRepository<StudentLabLevel, StudentLabLevelKey> {
|
||||
fun findByLabId(labId: UUID): List<StudentLabLevel>
|
||||
|
||||
fun findByStudentId(studentId: UUID): List<StudentLabLevel>
|
||||
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): StudentLabLevel?
|
||||
|
||||
fun findByStudentIdAndLabId(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): StudentLabLevel?
|
||||
|
||||
fun deleteByLabId(labId: UUID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,16 @@ import java.util.UUID
|
||||
|
||||
interface SubmissionRepository : JpaRepository<Submission, UUID> {
|
||||
fun findByStudentIdOrderByCreatedAtDesc(studentId: UUID): List<Submission>
|
||||
fun findByStudentIdAndLabIdOrderBySeqDesc(studentId: UUID, labId: UUID): List<Submission>
|
||||
|
||||
fun findByStudentIdAndLabIdOrderBySeqDesc(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): List<Submission>
|
||||
|
||||
fun findByLabIdOrderByCreatedAtDesc(labId: UUID): List<Submission>
|
||||
fun findFirstByStudentIdAndLabIdOrderBySeqDesc(studentId: UUID, labId: UUID): Submission?
|
||||
}
|
||||
|
||||
fun findFirstByStudentIdAndLabIdOrderBySeqDesc(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): Submission?
|
||||
}
|
||||
|
||||
@@ -6,7 +6,13 @@ import java.util.UUID
|
||||
|
||||
interface VariantAssignmentRepository : JpaRepository<VariantAssignment, UUID> {
|
||||
fun findByStudentId(studentId: UUID): List<VariantAssignment>
|
||||
|
||||
fun findByLabId(labId: UUID): List<VariantAssignment>
|
||||
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): VariantAssignment?
|
||||
|
||||
fun findByStudentIdAndLabId(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): VariantAssignment?
|
||||
|
||||
fun deleteByLabId(labId: UUID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||
import org.springframework.security.config.http.SessionCreationPolicy
|
||||
import org.springframework.security.core.GrantedAuthority
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder
|
||||
@@ -20,7 +19,6 @@ import org.springframework.security.web.SecurityFilterChain
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
|
||||
|
||||
@@ -29,8 +27,11 @@ class SecurityConfig {
|
||||
val converter = JwtAuthenticationConverter()
|
||||
converter.setJwtGrantedAuthoritiesConverter { jwt ->
|
||||
val role = jwt.getClaimAsString("role")
|
||||
if (role.isNullOrBlank()) emptyList()
|
||||
else listOf(SimpleGrantedAuthority("ROLE_$role"))
|
||||
if (role.isNullOrBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(SimpleGrantedAuthority("ROLE_$role"))
|
||||
}
|
||||
}
|
||||
return converter
|
||||
}
|
||||
@@ -44,8 +45,7 @@ class SecurityConfig {
|
||||
.authorizeHttpRequests { auth ->
|
||||
auth.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
|
||||
auth.anyRequest().authenticated()
|
||||
}
|
||||
.oauth2ResourceServer { oauth ->
|
||||
}.oauth2ResourceServer { oauth ->
|
||||
oauth.jwt { jwt ->
|
||||
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
|
||||
}
|
||||
@@ -56,4 +56,4 @@ class SecurityConfig {
|
||||
companion object {
|
||||
private val JWK_URI = System.getenv("LMS_JWT_JWK_SET_URI") ?: "http://localhost:8081/api/v1/auth/jwks.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import org.springframework.mock.web.MockMultipartFile
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.progress.domain.Submission
|
||||
import ru.ysu.lms.progress.repo.SubmissionRepository
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.UUID
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
class SubmissionServiceTest {
|
||||
@TempDir
|
||||
lateinit var tempDir: Path
|
||||
|
||||
private val studentId = UUID.randomUUID()
|
||||
private val labId = UUID.randomUUID()
|
||||
|
||||
private fun service(): Pair<SubmissionService, SubmissionRepository> {
|
||||
val repo = mockk<SubmissionRepository>()
|
||||
val academic = mockk<AcademicClient>()
|
||||
var saved: Submission? = null
|
||||
every { academic.getLabSummary(any(), any()) } returns LabInfo(id = labId.toString(), deadlineAt = null)
|
||||
every { repo.findFirstByStudentIdAndLabIdOrderBySeqDesc(any(), any()) } returns null
|
||||
every { repo.save(any()) } answers {
|
||||
firstArg<Submission>().also { saved = it }
|
||||
}
|
||||
every { repo.findById(any()) } answers { java.util.Optional.ofNullable(saved) }
|
||||
val storage = SubmissionStorage(tempDir.toString())
|
||||
return SubmissionService(repo, storage, academic) to repo
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stores submitted source file and returns it in zip`() {
|
||||
val (service, _) = service()
|
||||
val file = MockMultipartFile("files", "main.c", "text/plain", "#include <stdio.h>\n".toByteArray())
|
||||
|
||||
val dto = service.submit(studentId, labId, listOf(file), "token")
|
||||
|
||||
assertThat(dto.files).containsExactly("main.c")
|
||||
assertThat(dto.seq).isEqualTo(1)
|
||||
|
||||
val (bytes, name) = service.download(studentId, UUID.fromString(dto.id), studentId, isAdmin = false)
|
||||
assertThat(name).endsWith(".zip")
|
||||
val entries = zipEntries(bytes)
|
||||
assertThat(entries.keys).containsExactly("main.c")
|
||||
assertThat(String(entries.getValue("main.c"), Charsets.UTF_8)).contains("#include")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects xml project file renamed to source extension`() {
|
||||
val (service, _) = service()
|
||||
val xml = "<Solution>\n <Configurations/>\n</Solution>".toByteArray()
|
||||
val file = MockMultipartFile("files", "ConsoleApplication1.C", "text/plain", xml)
|
||||
|
||||
assertThatThrownBy { service.submit(studentId, labId, listOf(file), "token") }
|
||||
.isInstanceOf(BadRequestException::class.java)
|
||||
.hasMessageContaining("файл проекта")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `allows zip archive`() {
|
||||
val (service, _) = service()
|
||||
val file = MockMultipartFile("files", "project.zip", "application/zip", byteArrayOf(0x50, 0x4B, 0x03, 0x04))
|
||||
|
||||
val dto = service.submit(studentId, labId, listOf(file), "token")
|
||||
|
||||
assertThat(dto.files).containsExactly("project.zip")
|
||||
}
|
||||
|
||||
private fun zipEntries(bytes: ByteArray): Map<String, ByteArray> {
|
||||
val result = linkedMapOf<String, ByteArray>()
|
||||
ZipInputStream(bytes.inputStream()).use { zin ->
|
||||
var e = zin.nextEntry
|
||||
while (e != null) {
|
||||
if (!e.isDirectory) result[e.name] = zin.readAllBytes()
|
||||
e = zin.nextEntry
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user