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