Phase 7: deployment, data import, UI hardening and security fixes

Docker/prod deployment:
- multi-stage Dockerfile (JDK21 build, JRE21 run), docker-compose.prod.yml,
  nginx config for lms.our-place.ru, deploy.sh; DEPLOY/IMPORT guides (UI flow)
- Fix Docker 401: LMS_JWT_JWK_SET_URI for academic/progress; forward-headers

Variant distribution (lab page only):
- round-robin by student level, shared random queue across selected groups,
  least-used variants, manual pins always preserved; occupied/dup labels
- setup page: single form + one «Сохранить всё», batch level/variant for
  selected students (checkboxes)

Grades, defense, materials:
- publication gate + comment lists; defense CSV import (2/3 cols, quotes)
- additional task after defense ("начать заново"); enroll/unenroll students
  from group page; temporary-password lock (no navigation until changed) and
  persistent "Смена пароля"; forced change once
- chapter page /learn/{c}; header: LMS home, Войти, user -> dashboard,
  compact theme toggle; responsive mobile header
- markdown: single newlines are soft breaks; highlight.js with light/dark themes
- lab/content imports ZIP-only (lab.yaml+README+variants/assets,
  chapters tree+assets); duplicate lab numbers rejected; variant ordering
  and date defaults honored

Fixes:
- 500 on student create (@MapsId set before persist); broken dialogs/JS refs in group page
This commit is contained in:
2026-09-14 22:01:46 +03:00
parent 08830c67f1
commit d04207302a
58 changed files with 3967 additions and 884 deletions

View File

@@ -1,6 +1,5 @@
package ru.ysu.lms.academic.lab
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
@@ -8,43 +7,25 @@ import com.fasterxml.jackson.module.kotlin.KotlinModule
import org.springframework.stereotype.Component
import ru.ysu.lms.common.BadRequestException
import java.io.ByteArrayInputStream
import java.math.BigDecimal
import java.util.zip.ZipInputStream
/**
* Импорт лабораторной из ZIP: `lab.yaml` (метаданные и уровни), `README.md`
* (условие), `variants/` (файлы вариантов с front-matter: level, number, title),
* каталог `assets/` (картинки).
*/
@Component
class LabImportParser {
private val yaml = ObjectMapper(YAMLFactory())
.registerModule(KotlinModule.Builder().build())
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
private val json = ObjectMapper()
fun parse(filename: String, bytes: ByteArray): LabImportBundle {
val lower = filename.lowercase()
if (!(lower.endsWith(".zip") || lower.endsWith(".json"))) {
throw BadRequestException("Формат должен быть .zip (рекомендуется) или .json")
if (!filename.lowercase().endsWith(".zip")) {
throw BadRequestException("Формат импорта — .zip (lab.yaml + README.md + variants/)")
}
return if (lower.endsWith(".json")) {
parseJson(bytes)
} else {
parseZip(bytes)
}
}
private fun parseJson(bytes: ByteArray): LabImportBundle {
val tree = json.readTree(bytes)
val meta = LabMeta(
number = tree.path("number").asInt(),
title = tree.path("title").asText(),
pointsMax = BigDecimal.valueOf(tree.path("points_max").asDouble(5.0)),
deadlineAt = tree.path("deadline").asText("").ifBlank { null },
defenseAt = tree.path("defense_at").asText("").ifBlank { null },
status = tree.path("status").asText("published"),
levels = parseLevels(tree),
)
val descriptionMd = tree.path("common_requirements").asText("")
val variants = parseVariantsFromTree(tree, meta.levels)
return LabImportBundle(meta, descriptionMd, variants)
return parseZip(bytes)
}
private fun parseZip(bytes: ByteArray): LabImportBundle {
@@ -90,42 +71,6 @@ class LabImportParser {
return LabImportBundle(meta, readmeMd, variants, assets)
}
private fun parseLevels(tree: JsonNode): List<LabLevelMeta> {
val levels = tree.path("levels")
if (levels.isArray && levels.size() > 0) {
return levels.map { n ->
LabLevelMeta(
code = n.path("code").asText(),
title = n.path("title").asText(""),
description = n.path("description").asText(""),
position = n.path("position").asInt(0),
)
}
}
return listOf(
LabLevelMeta("basic", "Основная группа", position = 1),
LabLevelMeta("advanced", "Повышенная сложность", position = 2),
)
}
private fun parseVariantsFromTree(tree: JsonNode, levels: List<LabLevelMeta>): List<VariantMeta> {
val variants = tree.path("variants")
if (!variants.isArray) return emptyList()
val levelMap = levels.associateBy { it.code }
return variants.map { v ->
val levelCode = normalizeLevel(v.path("level").asText("basic"))
if (!levelMap.containsKey(levelCode)) {
throw BadRequestException("Неизвестный уровень: ${v.path("level").asText()}")
}
VariantMeta(
level = levelCode,
number = v.path("number").asText(),
title = v.path("title").asText(""),
statement = v.path("statement").asText(""),
)
}
}
private fun parseVariantFile(path: String, content: String, levels: List<LabLevelMeta>): VariantMeta? {
val levelMap = levels.associateBy { it.code }
val lines = content.lines()

View File

@@ -35,6 +35,12 @@ class LabService(
val semester = semesters.findFirstByActiveTrue()
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
if (labs.existsByNumberAndSemesterId(bundle.meta.number, semester.id)) {
throw ConflictException(
"Лабораторная №${bundle.meta.number} уже существует. Удалите её перед повторным импортом.",
)
}
val lab = labs.save(Lab(
number = bundle.meta.number,
title = bundle.meta.title,
@@ -42,6 +48,8 @@ class LabService(
descriptionMd = bundle.descriptionMd,
pointsMax = bundle.meta.pointsMax,
status = bundle.meta.status,
deadlineAt = parseInstant(bundle.meta.deadlineAt),
defenseAt = parseInstant(bundle.meta.defenseAt),
))
val levelMetas = bundle.meta.levels.ifEmpty {
@@ -115,8 +123,10 @@ class LabService(
defenseAt = lab.defenseAt?.toString(),
pointsMax = lab.pointsMax,
levels = levels.map { level ->
// Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой).
val vs = variantsRepo.findByLabIdOrderByNumber(lab.id)
.filter { it.labLevel?.id == level.id }
.sortedWith(compareBy({ naturalNumber(it.number) }, { it.number }))
LabLevelDetail(
id = level.id.toString(),
code = level.code,
@@ -287,6 +297,9 @@ class LabService(
return semesters.save(Semester(title = t, year = y, active = true))
}
private fun naturalNumber(value: String): Int =
Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
private fun parseInstant(value: String?): Instant? {
val v = value?.takeIf { it.isNotBlank() } ?: return null
return runCatching { Instant.parse(v) }

View File

@@ -20,7 +20,7 @@ spring:
- id: progress
uri: ${LMS_PROGRESS_URI:http://localhost:8083}
predicates:
- Path=/api/v1/assign,/api/v1/assign/**,/api/v1/defenses,/api/v1/defenses/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/**,/api/v1/comment-templates,/api/v1/submissions/**
- Path=/api/v1/assign,/api/v1/assign/**,/api/v1/defenses,/api/v1/defenses/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/**,/api/v1/grade-publications/**,/api/v1/comment-templates,/api/v1/submissions/**
management:
endpoints:

View File

@@ -14,6 +14,7 @@ interface EnrollmentRepository : JpaRepository<Enrollment, UUID> {
fun findAllByGroupId(@Param("groupId") groupId: UUID): List<Enrollment>
fun findByStudent_AccountId(accountId: UUID): Enrollment?
fun findFirstByStudent_AccountId(accountId: UUID): Enrollment?
fun deleteByStudent_AccountId(accountId: UUID)

View File

@@ -5,6 +5,7 @@ import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.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
@@ -16,6 +17,8 @@ import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.common.BadRequestException
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import java.util.UUID
@RestController
@@ -46,6 +49,25 @@ class GroupController(private val groupService: GroupService) {
return groupService.import(name, file.originalFilename ?: "group.xlsx", file.bytes)
}
/** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */
@GetMapping("/{id}/candidates")
fun candidates(@PathVariable id: UUID): List<StudentCandidate> = groupService.candidates(id)
/** Зачислить существующего студента. */
@PostMapping("/{id}/students/{studentId}/enroll")
fun enroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail =
groupService.enroll(id, studentId)
/** Отчислить студента из группы. */
@DeleteMapping("/{id}/students/{studentId}")
fun unenroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail =
groupService.unenroll(id, studentId)
@GetMapping("/me")
@PreAuthorize("isAuthenticated()")
fun myGroup(@AuthenticationPrincipal jwt: Jwt): List<GroupSummary> =
groupService.groupOfStudent(UUID.fromString(jwt.subject))?.let { listOf(it) } ?: emptyList()
@GetMapping("/{id}")
fun detail(@PathVariable id: UUID): GroupDetail = groupService.detail(id)

View File

@@ -49,6 +49,7 @@ class GroupService(
tempPassword = password,
),
)
// Student.id — это account_id через @MapsId: ассоциацию задаём до persist.
val student = students.save(
Student(
studentNo = studentNo,
@@ -57,9 +58,9 @@ class GroupService(
middleName = request.middleName.trim(),
basis = request.basis.trim(),
note = request.note.trim(),
account = account,
),
)
student.account = account
enrollments.save(Enrollment(group = group, student = student))
return Credential(
id = account.id.toString(),
@@ -78,6 +79,61 @@ class GroupService(
return summary(groups.save(GroupEntity(name = name)))
}
/** Студенты, которых ещё нет в этой группе (включая состоящих в других). */
@Transactional(readOnly = true)
fun candidates(groupId: UUID): List<StudentCandidate> {
groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val memberIds = enrollments.findAllByGroupId(groupId).mapNotNull { it.student?.accountId }.toSet()
val currentByStudent = HashMap<UUID, Pair<UUID, String>>()
for (e in enrollments.findAll()) {
val sid = e.student?.accountId ?: continue
val gid = e.group?.id ?: continue
val name = e.group?.name ?: continue
currentByStudent[sid] = gid to name
}
return students.findAll()
.filter { it.accountId !in memberIds }
.map { s ->
val current = s.accountId?.let { currentByStudent[it] }
StudentCandidate(
id = s.accountId!!.toString(),
fullName = s.fullName,
studentNo = s.studentNo,
currentGroupId = current?.first?.toString(),
currentGroupName = current?.second,
)
}
.sortedWith(compareBy({ it.fullName }, { it.studentNo }))
}
/** Зачислить существующего студента; если он в другой группе — переводим. */
@Transactional
fun enroll(groupId: UUID, studentId: UUID): GroupDetail {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val student = students.findById(studentId).orElseThrow { NotFoundException("Студент не найден") }
val existing = enrollments.findByStudent_AccountId(studentId)
if (existing != null) {
if (existing.group?.id == group.id) {
throw ConflictException("Студент уже в этой группе")
}
// unique-констрейт на student_id: переводим перепривязкой, без delete+insert
existing.group = group
enrollments.save(existing)
} else {
enrollments.save(Enrollment(group = group, student = student))
}
return detail(groupId)
}
/** Отчислить из группы (студент и его аккаунт сохраняются). */
@Transactional
fun unenroll(groupId: UUID, studentId: UUID): GroupDetail {
val enrollment = enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
?: throw NotFoundException("Студент не входит в эту группу")
enrollments.delete(enrollment)
return detail(groupId)
}
@Transactional
fun import(requestedName: String?, filename: String, bytes: ByteArray): ImportResult {
val parsed = parser.parse(filename, bytes)
@@ -141,6 +197,17 @@ class GroupService(
return ImportResult(group.id.toString(), group.name, added, updated)
}
@Transactional(readOnly = true)
fun groupOfStudent(accountId: UUID): GroupSummary? {
val group = enrollments.findByStudent_AccountId(accountId)?.group ?: return null
return GroupSummary(
id = group.id.toString(),
name = group.name,
studentCount = enrollments.findAllByGroupId(group.id).size.toLong(),
taskDurationMin = group.taskDurationMin,
)
}
@Transactional(readOnly = true)
fun detail(groupId: UUID): GroupDetail {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }

View File

@@ -59,6 +59,14 @@ data class ResetPasswordResponse(
val password: String,
)
data class StudentCandidate(
val id: String,
val fullName: String,
val studentNo: String,
val currentGroupId: String?,
val currentGroupName: String?,
)
class ExportFile(
val bytes: ByteArray,
val filename: String,

View File

@@ -35,3 +35,8 @@ management:
springdoc:
api-docs:
path: /v3/api-docs
lms:
jwt:
# Срок жизни access-токена; сессия web-BFF — 30 дней (см. web/application.yml).
access-token-ttl: ${LMS_JWT_ACCESS_TOKEN_TTL:30d}

View File

@@ -17,9 +17,13 @@ data class LevelAssignmentResponse(
)
data class DistributeRequest(
val labId: String,
/** If true, re-assigns even existing manual assignments */
val force: Boolean = false,
val labId: String? = null,
/** Можно передать несколько лабораторных за один вызов. */
val labIds: List<String>? = null,
/** Пересоздать варианты заново, сбросив текущие назначения. */
val force: Boolean? = null,
/** Ограничить группы; пусто — все группы. */
val groupIds: List<String>? = null,
)
data class VariantAssignmentResponse(
@@ -31,10 +35,11 @@ data class VariantAssignmentResponse(
)
data class DistributeResult(
val labId: String,
val labTitle: String,
val labs: Int = 1,
val assigned: Int,
val total: Int,
val labId: String? = null,
val labTitle: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)

View File

@@ -0,0 +1,31 @@
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) {
@Transactional
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
@Transactional(readOnly = true)
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() }
}

View File

@@ -17,6 +17,7 @@ import java.util.UUID
@RequestMapping("/api/v1")
class ProgressController(
private val service: ProgressService,
private val publications: GradePublicationService,
private val submissions: SubmissionService,
private val academic: AcademicClient,
) {
@@ -36,8 +37,10 @@ class ProgressController(
@PostMapping("/levels")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun setLevel(@Valid @RequestBody body: LevelAssignmentRequest): LevelAssignmentResponse =
service.setLevel(body)
fun setLevel(
@Valid @RequestBody body: LevelAssignmentRequest,
@AuthenticationPrincipal jwt: Jwt,
): LevelAssignmentResponse = service.setLevel(body, jwt.tokenValue)
@GetMapping("/levels")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
@@ -80,9 +83,31 @@ class ProgressController(
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun listGrades(): List<GradeResponse> = service.listGrades()
@PostMapping("/grades/publish")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
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)
@GetMapping("/grades/publications")
@PreAuthorize("isAuthenticated()")
fun groupPublications(@RequestParam groupId: java.util.UUID): List<String> =
publications.publishedLabIdsForGroup(groupId)
@GetMapping("/me/grades")
fun myGrades(@AuthenticationPrincipal jwt: Jwt): List<GradeResponse> =
service.listGrades().filter { it.studentId == jwt.subject }
fun myGrades(
@AuthenticationPrincipal jwt: Jwt,
@RequestParam(required = false) groupId: java.util.UUID?,
): List<GradeResponse> {
val all = service.listGrades().filter { it.studentId == jwt.subject }
if (groupId == null) return emptyList()
return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId)) }
}
@GetMapping("/comment-templates")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")

View File

@@ -30,19 +30,39 @@ class ProgressService(
) {
@Transactional
fun setLevel(request: LevelAssignmentRequest): LevelAssignmentResponse {
val key = StudentLabLevelKey(
UUID.fromString(request.studentId),
UUID.fromString(request.labId),
)
fun setLevel(request: LevelAssignmentRequest, token: String): LevelAssignmentResponse {
val sid = UUID.fromString(request.studentId)
val lid = UUID.fromString(request.labId)
val key = StudentLabLevelKey(sid, lid)
val existing = levels.findById(key)
val entity = existing.orElse(StudentLabLevel(
studentId = UUID.fromString(request.studentId),
labId = UUID.fromString(request.labId),
))
val entity = existing.orElse(StudentLabLevel(studentId = sid, labId = lid))
entity.labLevelId = UUID.fromString(request.labLevelId)
entity.manual = true
levels.save(entity)
// Сразу подбираем вариант из выбранного уровня: админ видит результат,
// а не «пустой» вариант до нажатия «Распределить».
val lab = runCatching { academic.getLab(request.labId, token) }.getOrNull()
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
if (!currentInLevel) {
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
}
assignments.save(target)
}
}
return LevelAssignmentResponse(
studentId = request.studentId,
labId = request.labId,
@@ -61,73 +81,95 @@ class ProgressService(
@Transactional
fun distribute(request: DistributeRequest, token: String): DistributeResult {
val labId = UUID.fromString(request.labId)
val lab = academic.getLab(request.labId, token)
val levelMap = lab.levels.associateBy { it.id }
val labIds = request.labIds.orEmpty().ifEmpty { listOfNotNull(request.labId) }
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
if (labIds.isEmpty()) throw BadRequestException("Не указана ни одна лабораторная")
if (request.force) {
assignments.deleteByLabId(labId)
}
val groups = identity.listGroups(token)
val students = mutableListOf<Pair<UUID, UUID>>() // (studentId, labLevelId)
for (g in groups) {
val force = request.force == true
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
val group = identity.getGroup(gid, token)
for (s in group.students) {
val sid = UUID.fromString(s.id)
val existingLevel = levels.findByStudentIdAndLabId(sid, labId)
val levelId = if (existingLevel != null) {
existingLevel.labLevelId
} else {
val levelInfo = lab.levels.firstOrNull() ?: continue
UUID.fromString(levelInfo.id)
if (groupFilter.isNotEmpty() && gid !in groupFilter) continue
for (s in identity.getGroup(gid, token).students) {
studentIds.add(UUID.fromString(s.id))
}
}
val allStudents = studentIds.distinct()
var assignedTotal = 0
var totalStudents = 0
var lastLabId: String? = null
var lastLabTitle: String? = null
for (labIdStr in labIds) {
val labId = UUID.fromString(labIdStr)
val lab = academic.getLab(labIdStr, token)
// force сбрасывает авто-назначения только у выбранных студентов;
// закреплённые вручную варианты остаются как есть.
if (force) {
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)
.filter { it.studentId in allStudents }
.forEach { kept[it.studentId] = it }
val toAssign = allStudents.filterNot { kept.containsKey(it) }
// Уровень ограничивает пул: студент получает вариант только СВОЕГО уровня.
// При этом очередь одна общая для всех выбранных групп: круг не
// «перезапускается» на каждой группе, а продолжается по наименее
// занятым вариантам уровня (пока хоть один не выдан — повторов нет).
val poolsByLevel = lab.levels.associate { lv -> lv.id to lv.variants.map { it.id } }
val defaultLevel = lab.levels.firstOrNull()?.id
if (toAssign.isNotEmpty() && poolsByLevel.isNotEmpty()) {
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() }
if (pool.isEmpty()) continue
val minCount = pool.minOf { usage[it] ?: 0 }
val chosen = pool.filter { (usage[it] ?: 0) == minCount }.random()
assignments.save(
VariantAssignment(
studentId = sid,
labId = labId,
variantId = UUID.fromString(chosen),
manual = false,
),
)
usage[chosen] = (usage[chosen] ?: 0) + 1
assignedTotal++
}
students.add(sid to levelId)
}
}
val assigned = mutableSetOf<String>()
val variantIdsByLevel = lab.levels.associate { lv ->
lv.id to lv.variants.map { it.id }
}
val result = mutableListOf<VariantAssignment>()
for ((sid, levelId) in students) {
val existing = assignments.findByStudentIdAndLabId(sid, labId)
if (existing != null && existing.manual) {
result.add(existing)
assigned.add(existing.variantId.toString())
continue
}
if (existing != null && !request.force) {
result.add(existing)
assigned.add(existing.variantId.toString())
continue
}
val pool = variantIdsByLevel[levelId.toString()].orEmpty()
if (pool.isEmpty()) continue
val chosen = pool.firstOrNull { it !in assigned }
?: pool.first()
val entity = VariantAssignment(
studentId = sid,
labId = labId,
variantId = UUID.fromString(chosen),
manual = false,
)
assignments.save(entity)
result.add(entity)
assigned.add(chosen)
totalStudents += allStudents.size
lastLabId = labIdStr
lastLabTitle = lab.title
}
val title = lab.levels.firstOrNull()?.let { lab.title } ?: lab.title
return DistributeResult(
labId = request.labId,
labTitle = title,
assigned = result.size,
total = students.size,
labs = labIds.size,
assigned = assignedTotal,
total = totalStudents,
labId = if (labIds.size == 1) lastLabId else null,
labTitle = if (labIds.size == 1) lastLabTitle else null,
)
}

View File

@@ -0,0 +1,24 @@
package ru.ysu.lms.progress.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.IdClass
import jakarta.persistence.Table
import java.io.Serializable
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "grade_publications")
@IdClass(GradePublicationKey::class)
class GradePublication(
@Id @Column(name = "group_id", nullable = false) var groupId: UUID = UUID(0, 0),
@Id @Column(name = "lab_id", nullable = false) var labId: UUID = UUID(0, 0),
@Column(name = "published_at", nullable = false) var publishedAt: Instant = Instant.now(),
)
data class GradePublicationKey(
val groupId: UUID = UUID(0, 0),
val labId: UUID = UUID(0, 0),
) : Serializable

View File

@@ -0,0 +1,12 @@
package ru.ysu.lms.progress.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.progress.domain.GradePublication
import ru.ysu.lms.progress.domain.GradePublicationKey
import java.util.UUID
interface GradePublicationRepository : JpaRepository<GradePublication, GradePublicationKey> {
fun findByLabIdAndGroupId(labId: UUID, groupId: UUID): GradePublication?
fun findByLabId(labId: UUID): List<GradePublication>
fun findByGroupId(groupId: UUID): List<GradePublication>
}

View File

@@ -0,0 +1,8 @@
-- Grade publication flags: a teacher publishes grades for a group per lab.
CREATE TABLE grade_publications (
group_id uuid NOT NULL,
lab_id uuid NOT NULL,
published_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (group_id, lab_id)
);