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) }
|
||||
|
||||
Reference in New Issue
Block a user