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:
@@ -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()
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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("Группа не найдена") }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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')")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
Reference in New Issue
Block a user