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

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