Build: enable ktlint+detekt, format sources, add core unit tests

This commit is contained in:
2026-09-27 21:46:05 +03:00
parent 802616918f
commit f3e8b2668f
103 changed files with 3834 additions and 1909 deletions

View File

@@ -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()
}
}
}

View File

@@ -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)
}

View File

@@ -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(

View File

@@ -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(),

View File

@@ -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>,
)
)

View File

@@ -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() }
}

View File

@@ -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)
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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" }

View 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,
)
)

View File

@@ -23,4 +23,4 @@ class LabGrade(
data class LabGradeKey(
val studentId: UUID = UUID(0, 0),
val labId: UUID = UUID(0, 0),
) : Serializable
) : Serializable

View File

@@ -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

View File

@@ -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(),
)
)

View File

@@ -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(),
)
)

View File

@@ -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>
}

View File

@@ -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?
}

View File

@@ -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>
}

View File

@@ -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?
}

View File

@@ -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)
}
}

View File

@@ -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?
}

View File

@@ -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)
}
}

View File

@@ -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"
}
}
}

View File

@@ -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
}
}