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 5e60cc3819
commit cdf4fb0ee0
58 changed files with 3967 additions and 884 deletions

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