UI rework: modal dialogs, inline lab editing, manual creation, content management
- Dashboard: real stats (groups, students, labs, average grade), labs/groups summary lists, no placeholder tile - Lab page: view + edit combined — metadata form in modal, condition rendered as markdown, per-level variant tables with add/edit/delete dialogs, level rename/delete, student level/variant selectors with group filter - Labs list: create-lab dialog with manual level entry (code; title lines) - Groups: create-group dialog, add-student dialog (auto password, toast card) - Content: public /learn reader is the single place; admin buttons (new article with chapter/section create-or-select, rename/delete chapters and sections, move articles between sections, import zip) appear for admins; removed separate 'Контент' admin section - Buttons use btn classes instead of nav-link - Favicon: generated SVG book icon - Security: CSRF synchronizer token interceptor (replaced spring-security in web which broke logback under nested jars), session regenerated on login, hardened session cookie, gateway actuator limited to health,info - Jackson 3: nullable-safe request DTOs for labs (FAIL_ON_NULL_FOR_PRIMITIVES) - academic: POST /labs (manual create with levels), level add/rename/delete, content chapters/sections CRUD, article create/move/delete endpoints
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
package ru.ysu.lms.academic.content
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.UUID
|
||||
|
||||
@Component
|
||||
class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") root: String) {
|
||||
private val rootDir: Path = Path.of(root)
|
||||
|
||||
init {
|
||||
Files.createDirectories(rootDir)
|
||||
}
|
||||
|
||||
data class StoredAsset(val publicName: String, val storedPath: Path, val mime: String)
|
||||
|
||||
fun store(originalName: String, bytes: ByteArray): StoredAsset {
|
||||
if (bytes.size > MAX_ASSET_BYTES) {
|
||||
throw BadRequestException("Файл $originalName слишком большой (> ${MAX_ASSET_BYTES / 1024 / 1024} МБ)")
|
||||
}
|
||||
val ext = originalName.substringAfterLast('.', "").lowercase()
|
||||
val allowed = setOf("png", "jpg", "jpeg", "gif", "svg", "webp")
|
||||
if (ext !in allowed) {
|
||||
throw BadRequestException("Недопустимый тип файла: $originalName (разрешены ${allowed.joinToString(", ")})")
|
||||
}
|
||||
val publicName = "${UUID.randomUUID()}.$ext"
|
||||
val target = rootDir.resolve(publicName).normalize()
|
||||
if (!target.startsWith(rootDir)) {
|
||||
throw BadRequestException("Некорректный путь файла")
|
||||
}
|
||||
Files.write(target, bytes)
|
||||
return StoredAsset(publicName, target, mimeFor(ext))
|
||||
}
|
||||
|
||||
fun read(publicName: String): Pair<Path, String>? {
|
||||
val clean = publicName.substringAfterLast('/')
|
||||
val target = rootDir.resolve(clean).normalize()
|
||||
if (!target.startsWith(rootDir) || target.parent != rootDir) return null
|
||||
if (!Files.isRegularFile(target)) return null
|
||||
val ext = clean.substringAfterLast('.', "").lowercase()
|
||||
return target to mimeFor(ext)
|
||||
}
|
||||
|
||||
private fun mimeFor(ext: String): String = when (ext) {
|
||||
"png" -> "image/png"
|
||||
"jpg", "jpeg" -> "image/jpeg"
|
||||
"gif" -> "image/gif"
|
||||
"svg" -> "image/svg+xml"
|
||||
"webp" -> "image/webp"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_ASSET_BYTES = 10 * 1024 * 1024
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package ru.ysu.lms.academic.content
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.academic.domain.ContentArticle
|
||||
import ru.ysu.lms.academic.domain.ContentChapter
|
||||
import ru.ysu.lms.academic.domain.ContentSection
|
||||
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
||||
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
||||
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.common.NotFoundException
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.util.UUID
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
data class ArticleTreeNode(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val position: Int,
|
||||
)
|
||||
|
||||
data class SectionTreeNode(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val position: Int,
|
||||
val articles: List<ArticleTreeNode>,
|
||||
)
|
||||
|
||||
data class ChapterTreeNode(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val position: Int,
|
||||
val sections: List<SectionTreeNode>,
|
||||
)
|
||||
|
||||
data class ContentTree(val chapters: List<ChapterTreeNode>)
|
||||
|
||||
data class ContentArticleView(
|
||||
val id: String,
|
||||
val chapterSlug: String,
|
||||
val chapterTitle: String,
|
||||
val sectionSlug: String,
|
||||
val sectionTitle: String,
|
||||
val slug: String,
|
||||
val title: String,
|
||||
val bodyMd: String,
|
||||
)
|
||||
|
||||
data class ContentImportResult(
|
||||
val chapters: Int,
|
||||
val sections: Int,
|
||||
val articles: Int,
|
||||
val assets: Int,
|
||||
)
|
||||
|
||||
private data class ParsedArticle(
|
||||
val path: String,
|
||||
val chapterPos: Int,
|
||||
val chapterDir: String,
|
||||
val sectionPos: Int,
|
||||
val sectionDir: String,
|
||||
val articlePos: Int,
|
||||
val fileTitle: String,
|
||||
val fmSlug: String?,
|
||||
val body: String,
|
||||
)
|
||||
|
||||
@Service
|
||||
class ContentService(
|
||||
private val chapters: ContentChapterRepository,
|
||||
private val sections: ContentSectionRepository,
|
||||
private val articles: ContentArticleRepository,
|
||||
private val assets: AssetStorage,
|
||||
) {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun tree(): ContentTree {
|
||||
val chapterList = chapters.findAllByOrderByPositionAscIdAsc()
|
||||
val sectionList = sections.findAllByOrderByPositionAscIdAsc().groupBy { it.chapterId }
|
||||
val articleList = articles.findAllByOrderByPositionAscIdAsc().groupBy { it.sectionId }
|
||||
return ContentTree(
|
||||
chapterList.map { ch ->
|
||||
ChapterTreeNode(
|
||||
id = ch.id.toString(),
|
||||
title = ch.title,
|
||||
slug = ch.slug,
|
||||
position = ch.position,
|
||||
sections = (sectionList[ch.id] ?: emptyList()).map { sec ->
|
||||
SectionTreeNode(
|
||||
id = sec.id.toString(),
|
||||
title = sec.title,
|
||||
slug = sec.slug,
|
||||
position = sec.position,
|
||||
articles = (articleList[sec.id] ?: emptyList()).map { a ->
|
||||
ArticleTreeNode(a.id.toString(), a.title, a.slug, a.position)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findArticle(chapterSlug: String, sectionSlug: String, articleSlug: String): ContentArticleView {
|
||||
val ch = chapters.findBySlug(chapterSlug) ?: throw NotFoundException("Глава не найдена")
|
||||
val sec = sections.findByChapterIdAndSlug(ch.id, sectionSlug) ?: throw NotFoundException("Раздел не найден")
|
||||
val a = articles.findBySectionIdAndSlug(sec.id, articleSlug) ?: throw NotFoundException("Статья не найдена")
|
||||
return ContentArticleView(
|
||||
id = a.id.toString(),
|
||||
chapterSlug = ch.slug,
|
||||
chapterTitle = ch.title,
|
||||
sectionSlug = sec.slug,
|
||||
sectionTitle = sec.title,
|
||||
slug = a.slug,
|
||||
title = a.title,
|
||||
bodyMd = a.bodyMd,
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun listAll(): List<ContentArticleView> {
|
||||
val chById = chapters.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
|
||||
val secById = sections.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
|
||||
return articles.findAllByOrderByPositionAscIdAsc().mapNotNull { a ->
|
||||
val sec = secById[a.sectionId] ?: return@mapNotNull null
|
||||
val ch = chById[sec.chapterId] ?: return@mapNotNull null
|
||||
ContentArticleView(
|
||||
id = a.id.toString(),
|
||||
chapterSlug = ch.slug,
|
||||
chapterTitle = ch.title,
|
||||
sectionSlug = sec.slug,
|
||||
sectionTitle = sec.title,
|
||||
slug = a.slug,
|
||||
title = a.title,
|
||||
bodyMd = a.bodyMd,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateArticle(articleId: UUID, title: String, bodyMd: String) {
|
||||
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
|
||||
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
|
||||
a.title = title.trim()
|
||||
a.bodyMd = bodyMd
|
||||
articles.save(a)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteArticle(articleId: UUID) {
|
||||
if (!articles.existsById(articleId)) throw NotFoundException("Статья не найдена")
|
||||
articles.deleteById(articleId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun createChapter(title: String, slug: String?): ChapterTreeNode {
|
||||
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
|
||||
val cleanSlug = slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
|
||||
?: uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
|
||||
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
|
||||
val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1))
|
||||
return ChapterTreeNode(ch.id.toString(), ch.title, ch.slug, ch.position, emptyList())
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun renameChapter(chapterId: UUID, title: String) {
|
||||
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
|
||||
ch.title = title.trim()
|
||||
chapters.save(ch)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteChapter(chapterId: UUID) {
|
||||
if (!chapters.existsById(chapterId)) throw NotFoundException("Глава не найдена")
|
||||
chapters.deleteById(chapterId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun createSection(chapterId: UUID, title: String, slug: String?): SectionTreeNode {
|
||||
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым")
|
||||
val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id)
|
||||
val cleanSlug = slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
|
||||
?: uniqueSlug(title, siblings.map { it.slug }.toSet())
|
||||
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
|
||||
val sec = sections.save(
|
||||
ContentSection(chapterId = ch.id, title = title.trim(), slug = cleanSlug, position = maxPos + 1),
|
||||
)
|
||||
return SectionTreeNode(sec.id.toString(), sec.title, sec.slug, sec.position, emptyList())
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun renameSection(sectionId: UUID, title: String) {
|
||||
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
|
||||
sec.title = title.trim()
|
||||
sections.save(sec)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteSection(sectionId: UUID) {
|
||||
if (!sections.existsById(sectionId)) throw NotFoundException("Раздел не найден")
|
||||
sections.deleteById(sectionId)
|
||||
}
|
||||
|
||||
/** Переносит все статьи раздела в другой и удаляет раздел. */
|
||||
@Transactional
|
||||
fun moveArticlesAndDeleteSection(sectionId: UUID, targetSectionId: UUID) {
|
||||
val src = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
val dst = sections.findById(targetSectionId).orElseThrow { NotFoundException("Раздел назначения не найден") }
|
||||
if (src.id == dst.id) throw BadRequestException("Раздел назначения совпадает с исходным")
|
||||
val moving = articles.findBySectionIdOrderByPositionAscIdAsc(src.id)
|
||||
var pos = articles.findBySectionIdOrderByPositionAscIdAsc(dst.id).maxOfOrNull { it.position } ?: 0
|
||||
for (a in moving) {
|
||||
pos += 1
|
||||
a.sectionId = dst.id
|
||||
a.position = pos
|
||||
articles.save(a)
|
||||
}
|
||||
sections.delete(src)
|
||||
}
|
||||
|
||||
/** Создаёт статью; если раздела нет — создаёт главу/раздел по названиям. */
|
||||
@Transactional
|
||||
fun createArticlePlaced(title: String, bodyMd: String, chapterId: UUID?, newChapterTitle: String?,
|
||||
sectionId: UUID?, newSectionTitle: String?): ContentArticleView {
|
||||
val targetSection: ContentSection = when {
|
||||
sectionId != null -> sections.findById(sectionId)
|
||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||
!newSectionTitle.isNullOrBlank() -> {
|
||||
val chapter: ContentChapter = when {
|
||||
chapterId != null -> chapters.findById(chapterId)
|
||||
.orElseThrow { NotFoundException("Глава не найдена") }
|
||||
!newChapterTitle.isNullOrBlank() -> {
|
||||
val node = createChapter(newChapterTitle, null)
|
||||
chapters.findById(UUID.fromString(node.id))
|
||||
.orElseThrow { NotFoundException("Глава не найдена") }
|
||||
}
|
||||
else -> throw BadRequestException("Укажите главу")
|
||||
}
|
||||
val node = createSection(chapter.id, newSectionTitle, null)
|
||||
sections.findById(UUID.fromString(node.id))
|
||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||
}
|
||||
else -> throw BadRequestException("Укажите раздел")
|
||||
}
|
||||
return createArticle(targetSection.id, title, bodyMd)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun createArticle(sectionId: UUID, title: String, bodyMd: String): ContentArticleView {
|
||||
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
|
||||
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
|
||||
val slug = uniqueSlug(title, siblings.map { it.slug }.toSet())
|
||||
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
|
||||
val a = articles.save(
|
||||
ContentArticle(sectionId = sec.id, title = title.trim(), slug = slug, bodyMd = bodyMd, position = maxPos + 1),
|
||||
)
|
||||
return ContentArticleView(
|
||||
id = a.id.toString(), chapterSlug = ch.slug, chapterTitle = ch.title,
|
||||
sectionSlug = sec.slug, sectionTitle = sec.title,
|
||||
slug = a.slug, title = a.title, bodyMd = a.bodyMd,
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun moveArticle(articleId: UUID, newSectionId: UUID): ContentArticleView {
|
||||
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
|
||||
val sec = sections.findById(newSectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
|
||||
a.sectionId = sec.id
|
||||
a.position = (siblings.maxOfOrNull { it.position } ?: 0) + 1
|
||||
articles.save(a)
|
||||
return ContentArticleView(
|
||||
id = a.id.toString(), chapterSlug = ch.slug, chapterTitle = ch.title,
|
||||
sectionSlug = sec.slug, sectionTitle = sec.title,
|
||||
slug = a.slug, title = a.title, bodyMd = a.bodyMd,
|
||||
)
|
||||
}
|
||||
|
||||
private fun uniqueSlug(title: String, taken: Set<String>): String {
|
||||
val base = slugify(title) ?: "item"
|
||||
if (base !in taken) return base
|
||||
var i = 2
|
||||
while ("$base-$i" in taken) i += 1
|
||||
return "$base-$i"
|
||||
}
|
||||
|
||||
/**
|
||||
* Импорт zip со структурой каталогов:
|
||||
* chapters/01-глава/01-раздел/01-статья.md (front-matter: title, slug),
|
||||
* картинки в assets/.
|
||||
*/
|
||||
@Transactional
|
||||
fun importZip(filename: String, bytes: ByteArray): ContentImportResult {
|
||||
if (!filename.lowercase().endsWith(".zip")) {
|
||||
throw BadRequestException("Ожидается .zip архив")
|
||||
}
|
||||
val files = readZip(bytes)
|
||||
if (files.isEmpty()) throw BadRequestException("Архив пуст")
|
||||
|
||||
val assetFiles = files.filterKeys { it.contains("/assets/") || it.startsWith("assets/") }
|
||||
val contentFiles = files.filterKeys { k ->
|
||||
(k.contains("/chapters/") || k.startsWith("chapters/")) && k.endsWith(".md")
|
||||
}
|
||||
if (contentFiles.isEmpty()) throw BadRequestException("В архиве нет каталога chapters/ с .md файлами")
|
||||
|
||||
val linkRewrite = mutableMapOf<String, String>()
|
||||
for ((path, data) in assetFiles) {
|
||||
val name = path.substringAfterLast('/')
|
||||
if (name.isBlank()) continue
|
||||
val stored = assets.store(name, data)
|
||||
linkRewrite["assets/$name"] = "/api/v1/content/assets/${stored.publicName}"
|
||||
}
|
||||
|
||||
val parsed = contentFiles.map { (path, data) ->
|
||||
val clean = path.removePrefix("/").removePrefix("chapters/").removeSuffix(".md")
|
||||
val parts = clean.split('/')
|
||||
if (parts.size < 3) {
|
||||
throw BadRequestException("Неверная структура: $path (ожидается chapters/NN-глава/NN-раздел/NN-статья.md)")
|
||||
}
|
||||
val chapterDir = parts[0]
|
||||
val sectionDir = parts[1]
|
||||
val fileName = parts.drop(2).joinToString("-")
|
||||
val parsedContent = splitFrontMatter(String(data, Charsets.UTF_8))
|
||||
ParsedArticle(
|
||||
path = path,
|
||||
chapterPos = positionOf(chapterDir),
|
||||
chapterDir = titleFromDir(chapterDir),
|
||||
sectionPos = positionOf(sectionDir),
|
||||
sectionDir = titleFromDir(sectionDir),
|
||||
articlePos = positionOf(fileName),
|
||||
fileTitle = parsedContent.first["title"] ?: titleFromDir(fileName),
|
||||
fmSlug = parsedContent.first["slug"],
|
||||
body = parsedContent.second,
|
||||
)
|
||||
}.sortedWith(
|
||||
compareBy(
|
||||
{ it.chapterPos },
|
||||
{ it.chapterDir },
|
||||
{ it.sectionPos },
|
||||
{ it.sectionDir },
|
||||
{ it.articlePos },
|
||||
),
|
||||
)
|
||||
|
||||
var chCount = 0
|
||||
var secCount = 0
|
||||
var artCount = 0
|
||||
var currentChapter: ContentChapter? = null
|
||||
var currentSection: ContentSection? = null
|
||||
|
||||
for (p in parsed) {
|
||||
if (currentChapter?.title != p.chapterDir) {
|
||||
val slug = chapterSlug(p) ?: "chapter-${chCount + 1}"
|
||||
currentChapter = chapters.findBySlug(slug) ?: chapters.save(
|
||||
ContentChapter(title = p.chapterDir, slug = slug, position = p.chapterPos),
|
||||
)
|
||||
chCount += 1
|
||||
currentSection = null
|
||||
}
|
||||
val chapter = currentChapter ?: throw BadRequestException("Не удалось определить главу")
|
||||
if (currentSection?.title != p.sectionDir) {
|
||||
val slug = sectionSlug(p) ?: "section-${secCount + 1}"
|
||||
currentSection = sections.findByChapterIdAndSlug(chapter.id, slug) ?: sections.save(
|
||||
ContentSection(chapterId = chapter.id, title = p.sectionDir, slug = slug, position = p.sectionPos),
|
||||
)
|
||||
secCount += 1
|
||||
}
|
||||
val section = currentSection ?: throw BadRequestException("Не удалось определить раздел")
|
||||
|
||||
var body = p.body
|
||||
for ((from, to) in linkRewrite) {
|
||||
body = body.replace(from, to)
|
||||
}
|
||||
val slug = p.fmSlug?.takeIf { it.isNotBlank() }
|
||||
?: articleSlug(p)
|
||||
?: "article-${artCount + 1}"
|
||||
val existing = articles.findBySectionIdAndSlug(section.id, slug)
|
||||
val article = (existing ?: ContentArticle(sectionId = section.id, slug = slug)).apply {
|
||||
title = p.fileTitle
|
||||
bodyMd = body
|
||||
position = p.articlePos
|
||||
}
|
||||
articles.save(article)
|
||||
artCount += 1
|
||||
}
|
||||
|
||||
return ContentImportResult(chapters = chCount, sections = secCount, articles = artCount, assets = linkRewrite.size)
|
||||
}
|
||||
|
||||
private fun chapterSlug(p: ParsedArticle): String? =
|
||||
slugify("%02d".format(p.chapterPos) + "-" + p.chapterDir)
|
||||
|
||||
private fun sectionSlug(p: ParsedArticle): String? =
|
||||
slugify("%02d".format(p.sectionPos) + "-" + p.sectionDir)
|
||||
|
||||
private fun articleSlug(p: ParsedArticle): String? =
|
||||
slugify("%02d".format(p.articlePos) + "-" + p.fileTitle)
|
||||
|
||||
private fun readZip(bytes: ByteArray): Map<String, ByteArray> {
|
||||
val result = mutableMapOf<String, ByteArray>()
|
||||
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
|
||||
var entry = zis.nextEntry
|
||||
while (entry != null) {
|
||||
if (!entry.isDirectory) {
|
||||
result[entry.name.removePrefix("/")] = zis.readAllBytes()
|
||||
}
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun splitFrontMatter(content: String): Pair<Map<String, String>, String> {
|
||||
if (!content.startsWith("---")) return emptyMap<String, String>() to content
|
||||
val lines = content.lines()
|
||||
val end = lines.withIndex()
|
||||
.firstOrNull { it.index != 0 && it.value.trim() == "---" }
|
||||
?.index ?: return emptyMap<String, String>() to content
|
||||
val fm = mutableMapOf<String, String>()
|
||||
for (line in lines.subList(1, end)) {
|
||||
val parts = line.split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val v = parts[1].trim()
|
||||
val unquoted = if (v.length >= 2 && v.first() == '"' && v.last() == '"') {
|
||||
v.substring(1, v.length - 1)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
fm[parts[0].trim()] = unquoted
|
||||
}
|
||||
}
|
||||
return fm to lines.drop(end + 1).joinToString("\n").trim()
|
||||
}
|
||||
|
||||
private fun positionOf(name: String): Int {
|
||||
val m = Regex("^(\\d{1,3})[-_]").find(name.substringAfterLast('/'))
|
||||
return m?.groupValues?.get(1)?.toIntOrNull() ?: 999
|
||||
}
|
||||
|
||||
private fun titleFromDir(dir: String): String {
|
||||
val raw = dir.substringAfterLast('/')
|
||||
.replace(Regex("^\\d{1,3}[-_]"), "")
|
||||
.replace('_', ' ')
|
||||
return raw.ifBlank { dir }
|
||||
}
|
||||
|
||||
private fun slugify(value: String): String? {
|
||||
val translit = TRANSLIT.entries.fold(value.lowercase()) { acc, (from, to) -> acc.replace(from, to) }
|
||||
val slug = translit
|
||||
.replace(Regex("\\s+"), "-")
|
||||
.replace(Regex("[^a-z0-9-]"), "")
|
||||
.replace(Regex("-+"), "-")
|
||||
.trim('-')
|
||||
return slug.ifBlank { null }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val TRANSLIT = mapOf(
|
||||
"а" to "a", "б" to "b", "в" to "v", "г" to "g", "д" to "d", "е" to "e", "ё" to "e",
|
||||
"ж" to "zh", "з" to "z", "и" to "i", "й" to "y", "к" to "k", "л" to "l", "м" to "m",
|
||||
"н" to "n", "о" to "o", "п" to "p", "р" to "r", "с" to "s", "т" to "t", "у" to "u",
|
||||
"ф" to "f", "х" to "h", "ц" to "c", "ч" to "ch", "ш" to "sh", "щ" to "sch", "ъ" to "",
|
||||
"ы" to "y", "ь" to "", "э" to "e", "ю" to "yu", "я" to "ya",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package ru.ysu.lms.academic.controller
|
||||
|
||||
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
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import ru.ysu.lms.academic.content.ContentImportResult
|
||||
import ru.ysu.lms.academic.content.ContentService
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/content")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
class ContentAdminController(private val contentService: ContentService) {
|
||||
|
||||
data class ArticleUpdatePayload(val title: String, val bodyMd: String)
|
||||
data class ArticleCreatePayload(
|
||||
val chapterId: String? = null,
|
||||
val sectionId: String? = null,
|
||||
val newChapterTitle: String? = null,
|
||||
val newSectionTitle: String? = null,
|
||||
val title: String,
|
||||
val bodyMd: String = "",
|
||||
)
|
||||
data class ArticleMovePayload(val sectionId: String)
|
||||
data class TitlePayload(val title: String)
|
||||
data class ChapterCreatePayload(val title: String, val slug: String? = null)
|
||||
data class SectionCreatePayload(val title: String, val slug: String? = null)
|
||||
|
||||
@PostMapping("/import")
|
||||
fun import(@RequestParam("file") file: MultipartFile): ContentImportResult {
|
||||
if (file.isEmpty) throw BadRequestException("Выберите файл")
|
||||
return contentService.importZip(file.originalFilename ?: "content.zip", file.bytes)
|
||||
}
|
||||
|
||||
@GetMapping("/articles")
|
||||
fun list() = contentService.listAll()
|
||||
|
||||
@PostMapping("/chapters")
|
||||
fun createChapter(@RequestBody body: ChapterCreatePayload) = contentService.createChapter(body.title, body.slug)
|
||||
|
||||
@PutMapping("/chapters/{id}")
|
||||
fun renameChapter(@PathVariable id: UUID, @RequestBody body: TitlePayload) =
|
||||
contentService.renameChapter(id, body.title)
|
||||
|
||||
@DeleteMapping("/chapters/{id}")
|
||||
fun deleteChapter(@PathVariable id: UUID): ResponseEntity<Void> {
|
||||
contentService.deleteChapter(id)
|
||||
return ResponseEntity.noContent().build()
|
||||
}
|
||||
|
||||
@PostMapping("/sections")
|
||||
fun createSection(@RequestParam chapterId: UUID, @RequestBody body: SectionCreatePayload) =
|
||||
contentService.createSection(chapterId, body.title, body.slug)
|
||||
|
||||
@PutMapping("/sections/{id}")
|
||||
fun renameSection(@PathVariable id: UUID, @RequestBody body: TitlePayload) =
|
||||
contentService.renameSection(id, body.title)
|
||||
|
||||
@DeleteMapping("/sections/{id}")
|
||||
fun deleteSection(
|
||||
@PathVariable id: UUID,
|
||||
@RequestParam(required = false) moveTo: UUID?,
|
||||
): ResponseEntity<Void> {
|
||||
if (moveTo != null) {
|
||||
contentService.moveArticlesAndDeleteSection(id, moveTo)
|
||||
} else {
|
||||
contentService.deleteSection(id)
|
||||
}
|
||||
return ResponseEntity.noContent().build()
|
||||
}
|
||||
|
||||
@PostMapping("/articles")
|
||||
fun createArticle(@RequestBody body: ArticleCreatePayload) = contentService.createArticlePlaced(
|
||||
title = body.title,
|
||||
bodyMd = body.bodyMd,
|
||||
chapterId = body.chapterId?.let { UUID.fromString(it) },
|
||||
newChapterTitle = body.newChapterTitle,
|
||||
sectionId = body.sectionId?.let { UUID.fromString(it) },
|
||||
newSectionTitle = body.newSectionTitle,
|
||||
)
|
||||
|
||||
@PutMapping("/articles/{id}")
|
||||
fun update(@PathVariable id: UUID, @RequestBody body: ArticleUpdatePayload) {
|
||||
contentService.updateArticle(id, body.title, body.bodyMd)
|
||||
}
|
||||
|
||||
@PostMapping("/articles/{id}/move")
|
||||
fun move(@PathVariable id: UUID, @RequestBody body: ArticleMovePayload) =
|
||||
contentService.moveArticle(id, UUID.fromString(body.sectionId))
|
||||
|
||||
@DeleteMapping("/articles/{id}")
|
||||
fun delete(@PathVariable id: UUID): ResponseEntity<Void> {
|
||||
contentService.deleteArticle(id)
|
||||
return ResponseEntity.noContent().build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package ru.ysu.lms.academic.controller
|
||||
|
||||
import org.springframework.core.io.FileSystemResource
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import ru.ysu.lms.academic.content.AssetStorage
|
||||
import ru.ysu.lms.academic.content.ContentService
|
||||
import java.nio.file.Files
|
||||
|
||||
/** Публичное чтение учебных материалов — доступно без аутентификации. */
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/content")
|
||||
class ContentPublicController(
|
||||
private val contentService: ContentService,
|
||||
private val assetStorage: AssetStorage,
|
||||
) {
|
||||
|
||||
@GetMapping("/tree")
|
||||
fun tree() = contentService.tree()
|
||||
|
||||
@GetMapping("/{chapter}/{section}/{article}")
|
||||
fun article(
|
||||
@PathVariable chapter: String,
|
||||
@PathVariable section: String,
|
||||
@PathVariable article: String,
|
||||
): ContentArticleViewDto {
|
||||
val a = contentService.findArticle(chapter, section, article)
|
||||
return ContentArticleViewDto(
|
||||
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle,
|
||||
sectionSlug = a.sectionSlug, sectionTitle = a.sectionTitle,
|
||||
slug = a.slug, title = a.title, bodyMd = a.bodyMd,
|
||||
)
|
||||
}
|
||||
|
||||
data class ContentArticleViewDto(
|
||||
val id: String,
|
||||
val chapterSlug: String,
|
||||
val chapterTitle: String,
|
||||
val sectionSlug: String,
|
||||
val sectionTitle: String,
|
||||
val slug: String,
|
||||
val title: String,
|
||||
val bodyMd: String,
|
||||
)
|
||||
|
||||
@GetMapping("/assets/{name}")
|
||||
fun asset(@PathVariable name: String): ResponseEntity<FileSystemResource> {
|
||||
val (path, mime) = assetStorage.read(name)
|
||||
?: return ResponseEntity.notFound().build()
|
||||
val media = runCatching { MediaType.parseMediaType(mime) }.getOrElse { MediaType.APPLICATION_OCTET_STREAM }
|
||||
return ResponseEntity.ok()
|
||||
.contentType(media)
|
||||
.cacheControl(org.springframework.http.CacheControl.maxAge(java.time.Duration.ofDays(7)))
|
||||
.body(FileSystemResource(path))
|
||||
}
|
||||
}
|
||||
@@ -54,4 +54,28 @@ class LabController(private val labService: LabService) {
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun deleteVariant(@PathVariable id: UUID, @PathVariable variantId: UUID): LabDetail =
|
||||
labService.deleteVariant(id, variantId)
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun create(@Valid @RequestBody body: LabCreateRequest): LabDetail = labService.create(body)
|
||||
|
||||
@PostMapping("/{id}/levels")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun addLevel(@PathVariable id: UUID, @Valid @RequestBody body: LevelSaveRequest): LabDetail {
|
||||
labService.addLevel(id, body)
|
||||
return labService.detail(id)
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/levels/{levelId}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun renameLevel(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable levelId: UUID,
|
||||
@Valid @RequestBody body: LevelRenameRequest,
|
||||
): LabDetail = labService.renameLevel(id, levelId, body)
|
||||
|
||||
@DeleteMapping("/{id}/levels/{levelId}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun deleteLevel(@PathVariable id: UUID, @PathVariable levelId: UUID): LabDetail =
|
||||
labService.deleteLevel(id, levelId)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package ru.ysu.lms.academic.domain
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import ru.ysu.lms.common.Ids
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@Table(name = "chapters")
|
||||
class ContentChapter(
|
||||
@Id var id: UUID = Ids.v7(),
|
||||
@Column(nullable = false, length = 200) var title: String = "",
|
||||
@Column(nullable = false, unique = true, length = 200) var slug: String = "",
|
||||
@Column(nullable = false) var position: Int = 0,
|
||||
)
|
||||
|
||||
@Entity
|
||||
@Table(name = "sections")
|
||||
class ContentSection(
|
||||
@Id var id: UUID = Ids.v7(),
|
||||
@Column(nullable = false) var chapterId: UUID = UUID(0, 0),
|
||||
@Column(nullable = false, length = 200) var title: String = "",
|
||||
@Column(nullable = false, length = 200) var slug: String = "",
|
||||
@Column(nullable = false) var position: Int = 0,
|
||||
)
|
||||
|
||||
@Entity
|
||||
@Table(name = "articles")
|
||||
class ContentArticle(
|
||||
@Id var id: UUID = Ids.v7(),
|
||||
@Column(nullable = false) var sectionId: UUID = UUID(0, 0),
|
||||
@Column(nullable = false, length = 200) var title: String = "",
|
||||
@Column(nullable = false, length = 200) var slug: String = "",
|
||||
@Column(nullable = false) var bodyMd: String = "",
|
||||
@Column(nullable = false) var position: Int = 0,
|
||||
)
|
||||
@@ -16,7 +16,7 @@ data class LabLevelMeta(
|
||||
val code: String,
|
||||
val title: String,
|
||||
val description: String = "",
|
||||
val position: Int = 0,
|
||||
val position: Int? = null,
|
||||
val pointsMax: BigDecimal? = null,
|
||||
)
|
||||
|
||||
@@ -84,14 +84,39 @@ data class LabUpdateRequest(
|
||||
val title: String,
|
||||
val deadlineAt: String? = null,
|
||||
val defenseAt: String? = null,
|
||||
val pointsMax: BigDecimal,
|
||||
val status: String,
|
||||
val descriptionMd: String,
|
||||
val pointsMax: BigDecimal? = null,
|
||||
val status: String? = null,
|
||||
val descriptionMd: String? = null,
|
||||
)
|
||||
|
||||
data class VariantSaveRequest(
|
||||
val labLevelId: String,
|
||||
val number: String,
|
||||
val title: String? = null,
|
||||
val statementMd: String? = null,
|
||||
)
|
||||
|
||||
data class LevelSaveRequest(
|
||||
val code: String,
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
val position: Int? = null,
|
||||
)
|
||||
|
||||
data class LabCreateRequest(
|
||||
val number: Int? = null,
|
||||
val title: String? = null,
|
||||
val semesterTitle: String? = null,
|
||||
val semesterYear: Int? = null,
|
||||
val deadlineAt: String? = null,
|
||||
val defenseAt: String? = null,
|
||||
val pointsMax: BigDecimal? = null,
|
||||
val status: String? = null,
|
||||
val descriptionMd: String? = null,
|
||||
val levels: List<LevelSaveRequest> = emptyList(),
|
||||
)
|
||||
|
||||
data class LevelRenameRequest(
|
||||
val title: String,
|
||||
val statementMd: String,
|
||||
val description: String? = null,
|
||||
)
|
||||
@@ -4,6 +4,7 @@ import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.academic.domain.Lab
|
||||
import ru.ysu.lms.academic.domain.LabLevel
|
||||
import ru.ysu.lms.academic.domain.Semester
|
||||
import ru.ysu.lms.academic.domain.Variant
|
||||
import ru.ysu.lms.academic.repo.LabLevelRepository
|
||||
import ru.ysu.lms.academic.repo.LabRepository
|
||||
@@ -11,6 +12,7 @@ import ru.ysu.lms.academic.repo.SemesterRepository
|
||||
import ru.ysu.lms.academic.repo.VariantRepository
|
||||
import ru.ysu.lms.common.NotFoundException
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.common.ConflictException
|
||||
import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
@@ -53,8 +55,8 @@ class LabService(
|
||||
lab = lab,
|
||||
code = meta.code,
|
||||
title = meta.title,
|
||||
description = meta.description,
|
||||
position = meta.position,
|
||||
description = meta.description.orEmpty(),
|
||||
position = meta.position ?: 0,
|
||||
pointsMax = meta.pointsMax,
|
||||
))
|
||||
}
|
||||
@@ -147,9 +149,9 @@ class LabService(
|
||||
lab.title = request.title.trim()
|
||||
lab.deadlineAt = parseInstant(request.deadlineAt)
|
||||
lab.defenseAt = parseInstant(request.defenseAt)
|
||||
lab.pointsMax = request.pointsMax
|
||||
lab.status = request.status
|
||||
lab.descriptionMd = request.descriptionMd
|
||||
lab.pointsMax = request.pointsMax ?: lab.pointsMax
|
||||
lab.status = request.status ?: lab.status
|
||||
lab.descriptionMd = request.descriptionMd.orEmpty()
|
||||
labs.save(lab)
|
||||
return detail(labId)
|
||||
}
|
||||
@@ -159,10 +161,10 @@ class LabService(
|
||||
val variant = variantsRepo.findById(variantId)
|
||||
.orElseThrow { NotFoundException("Вариант не найден") }
|
||||
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
|
||||
if (request.number.isBlank()) throw BadRequestException("Номер варианта не может быть пустым")
|
||||
if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым")
|
||||
variant.number = request.number.trim()
|
||||
variant.title = request.title.trim()
|
||||
variant.statementMd = request.statementMd
|
||||
variant.title = request.title.orEmpty().trim()
|
||||
variant.statementMd = request.statementMd.orEmpty()
|
||||
request.labLevelId.takeIf { it.isNotBlank() }?.let { levelId ->
|
||||
variant.labLevel = labLevels.findById(UUID.fromString(levelId))
|
||||
.orElseThrow { BadRequestException("Уровень не найден") }
|
||||
@@ -174,15 +176,15 @@ class LabService(
|
||||
@Transactional
|
||||
fun addVariant(labId: UUID, request: VariantSaveRequest): LabDetail {
|
||||
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
||||
if (request.number.isBlank()) throw BadRequestException("Номер варианта не может быть пустым")
|
||||
if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым")
|
||||
val level = labLevels.findById(UUID.fromString(request.labLevelId))
|
||||
.orElseThrow { BadRequestException("Уровень не найден") }
|
||||
variantsRepo.save(Variant(
|
||||
lab = lab,
|
||||
labLevel = level,
|
||||
number = request.number.trim(),
|
||||
title = request.title.trim(),
|
||||
statementMd = request.statementMd,
|
||||
title = request.title.orEmpty().trim(),
|
||||
statementMd = request.statementMd.orEmpty(),
|
||||
))
|
||||
return detail(labId)
|
||||
}
|
||||
@@ -196,6 +198,88 @@ class LabService(
|
||||
return detail(labId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun create(request: LabCreateRequest): LabDetail {
|
||||
if (request.title.isNullOrBlank()) throw BadRequestException("Название не может быть пустым")
|
||||
if ((request.number ?: 0) <= 0) throw BadRequestException("Номер лабораторной должен быть положительным")
|
||||
val semester = findOrCreateSemester(request.semesterTitle.orEmpty(), request.semesterYear ?: 0)
|
||||
if (labs.existsByNumberAndSemesterId(request.number ?: 0, semester.id)) {
|
||||
throw ConflictException("Лабораторная №${request.number} уже существует в этом семестре")
|
||||
}
|
||||
val lab = labs.save(
|
||||
Lab(
|
||||
number = request.number ?: 0,
|
||||
title = request.title.orEmpty().trim(),
|
||||
semester = semester,
|
||||
descriptionMd = request.descriptionMd.orEmpty(),
|
||||
deadlineAt = parseInstant(request.deadlineAt),
|
||||
defenseAt = parseInstant(request.defenseAt),
|
||||
pointsMax = request.pointsMax ?: BigDecimal(5),
|
||||
status = request.status ?: "draft",
|
||||
),
|
||||
)
|
||||
val levelMetas = request.levels.ifEmpty {
|
||||
listOf(LevelSaveRequest(code = "basic", title = "Основная группа"))
|
||||
}
|
||||
levelMetas.forEachIndexed { index, meta ->
|
||||
if (meta.code.isNullOrBlank()) throw BadRequestException("Код уровня не может быть пустым")
|
||||
labLevels.save(
|
||||
LabLevel(
|
||||
lab = lab,
|
||||
code = meta.code.trim(),
|
||||
title = meta.title.orEmpty().ifBlank { meta.code },
|
||||
description = meta.description.orEmpty(),
|
||||
position = meta.position?.takeIf { it > 0 } ?: index + 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
return detail(lab.id)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun renameLevel(labId: UUID, levelId: UUID, request: LevelRenameRequest): LabDetail {
|
||||
val level = labLevels.findById(levelId).orElseThrow { NotFoundException("Уровень не найден") }
|
||||
if (level.lab?.id != labId) throw NotFoundException("Уровень не найден")
|
||||
level.title = request.title.ifBlank { level.title }
|
||||
level.description = request.description.orEmpty()
|
||||
labLevels.save(level)
|
||||
return detail(labId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteLevel(labId: UUID, levelId: UUID): LabDetail {
|
||||
val level = labLevels.findById(levelId).orElseThrow { NotFoundException("Уровень не найден") }
|
||||
if (level.lab?.id != labId) throw NotFoundException("Уровень не найден")
|
||||
if (variantsRepo.findByLabIdOrderByNumber(labId).any { it.labLevel?.id == levelId }) {
|
||||
throw ConflictException("У уровня есть варианты — сначала удалите их")
|
||||
}
|
||||
labLevels.delete(level)
|
||||
return detail(labId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun addLevel(labId: UUID, request: LevelSaveRequest) {
|
||||
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
||||
if (request.code.isNullOrBlank()) throw BadRequestException("Код уровня не может быть пустым")
|
||||
val existing = labLevels.findByLabIdOrderByPosition(labId)
|
||||
labLevels.save(
|
||||
LabLevel(
|
||||
lab = lab,
|
||||
code = request.code.trim(),
|
||||
title = request.title.orEmpty().ifBlank { request.code },
|
||||
description = request.description.orEmpty(),
|
||||
position = request.position?.takeIf { it > 0 } ?: (existing.size + 1),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun findOrCreateSemester(title: String, year: Int): Semester {
|
||||
semesters.findFirstByActiveTrue()?.let { return it }
|
||||
val t = title.ifBlank { "осень" }
|
||||
val y = if (year > 0) year else java.time.LocalDate.now().year
|
||||
return semesters.save(Semester(title = t, year = y, active = true))
|
||||
}
|
||||
|
||||
private fun parseInstant(value: String?): Instant? {
|
||||
val v = value?.takeIf { it.isNotBlank() } ?: return null
|
||||
return runCatching { Instant.parse(v) }
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package ru.ysu.lms.academic.repo
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import ru.ysu.lms.academic.domain.ContentArticle
|
||||
import ru.ysu.lms.academic.domain.ContentChapter
|
||||
import ru.ysu.lms.academic.domain.ContentSection
|
||||
import java.util.UUID
|
||||
|
||||
interface ContentChapterRepository : JpaRepository<ContentChapter, UUID> {
|
||||
fun findAllByOrderByPositionAscIdAsc(): List<ContentChapter>
|
||||
fun findBySlug(slug: String): ContentChapter?
|
||||
}
|
||||
|
||||
interface ContentSectionRepository : JpaRepository<ContentSection, UUID> {
|
||||
fun findAllByOrderByPositionAscIdAsc(): List<ContentSection>
|
||||
fun findByChapterIdOrderByPositionAscIdAsc(chapterId: UUID): List<ContentSection>
|
||||
fun findByChapterIdAndSlug(chapterId: UUID, slug: String): ContentSection?
|
||||
}
|
||||
|
||||
interface ContentArticleRepository : JpaRepository<ContentArticle, UUID> {
|
||||
fun findBySectionIdOrderByPositionAscIdAsc(sectionId: UUID): List<ContentArticle>
|
||||
fun findBySectionIdAndSlug(sectionId: UUID, slug: String): ContentArticle?
|
||||
fun findAllByOrderByPositionAscIdAsc(): List<ContentArticle>
|
||||
}
|
||||
@@ -6,4 +6,5 @@ import java.util.UUID
|
||||
|
||||
interface LabRepository : JpaRepository<Lab, UUID> {
|
||||
fun findAllByOrderByNumberAsc(): List<Lab>
|
||||
fun existsByNumberAndSemesterId(number: Int, semesterId: UUID): Boolean
|
||||
}
|
||||
@@ -43,7 +43,14 @@ class SecurityConfig {
|
||||
.cors { it.disable() }
|
||||
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
||||
.authorizeHttpRequests { auth ->
|
||||
auth.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
|
||||
auth.requestMatchers(
|
||||
"/actuator/**",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-ui/**",
|
||||
"/api/v1/content/tree",
|
||||
"/api/v1/content/assets/**",
|
||||
"/api/v1/content/{chapter}/{section}/{article}",
|
||||
).permitAll()
|
||||
auth.anyRequest().authenticated()
|
||||
}
|
||||
.oauth2ResourceServer { oauth ->
|
||||
|
||||
@@ -35,3 +35,7 @@ management:
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /v3/api-docs
|
||||
|
||||
lms:
|
||||
content:
|
||||
assets-dir: ${LMS_CONTENT_ASSETS_DIR:data/content-assets}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Content: chapters -> sections -> articles (public learning materials).
|
||||
-- The previously unused courses table is replaced by the chapters/sections model.
|
||||
|
||||
ALTER TABLE articles DROP CONSTRAINT IF EXISTS articles_course_slug_unique;
|
||||
ALTER TABLE articles DROP CONSTRAINT IF EXISTS articles_course_id_fkey;
|
||||
ALTER TABLE articles RENAME COLUMN course_id TO section_id;
|
||||
|
||||
CREATE TABLE chapters (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
title varchar(200) NOT NULL,
|
||||
slug varchar(200) NOT NULL UNIQUE,
|
||||
position integer NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE sections (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
chapter_id uuid NOT NULL REFERENCES chapters(id) ON DELETE CASCADE,
|
||||
title varchar(200) NOT NULL,
|
||||
slug varchar(200) NOT NULL,
|
||||
position integer NOT NULL DEFAULT 0,
|
||||
CONSTRAINT sections_chapter_slug_unique UNIQUE (chapter_id, slug)
|
||||
);
|
||||
|
||||
ALTER TABLE articles
|
||||
ADD CONSTRAINT articles_section_fk FOREIGN KEY (section_id) REFERENCES sections(id) ON DELETE CASCADE;
|
||||
ALTER TABLE articles
|
||||
ADD CONSTRAINT articles_section_slug_unique UNIQUE (section_id, slug);
|
||||
|
||||
DROP TABLE IF EXISTS courses;
|
||||
@@ -45,7 +45,8 @@ class JwtAuthenticationFilter(private val jwtDecoder: JwtDecoder) : GlobalFilter
|
||||
path == "/api/v1/auth/login" ||
|
||||
path == "/api/v1/auth/setup" ||
|
||||
path == "/api/v1/auth/health" ||
|
||||
path == "/api/v1/auth/jwks.json"
|
||||
path == "/api/v1/auth/jwks.json" ||
|
||||
path.startsWith("/api/v1/content/")
|
||||
|
||||
private fun bearerToken(exchange: ServerWebExchange): String? {
|
||||
val header = exchange.request.headers.getFirst("Authorization") ?: return null
|
||||
|
||||
@@ -16,7 +16,7 @@ spring:
|
||||
- id: academic
|
||||
uri: ${LMS_ACADEMIC_URI:http://localhost:8082}
|
||||
predicates:
|
||||
- Path=/api/v1/courses/**,/api/v1/articles/**,/api/v1/labs/**,/api/v1/assets/**
|
||||
- Path=/api/v1/courses/**,/api/v1/articles/**,/api/v1/labs,/api/v1/labs/**,/api/v1/assets/**,/api/v1/content/**
|
||||
- id: progress
|
||||
uri: ${LMS_PROGRESS_URI:http://localhost:8083}
|
||||
predicates:
|
||||
@@ -26,7 +26,7 @@ management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,gateway
|
||||
include: health,info
|
||||
endpoint:
|
||||
health:
|
||||
probes:
|
||||
|
||||
@@ -29,6 +29,12 @@ class GroupController(private val groupService: GroupService) {
|
||||
@PostMapping
|
||||
fun create(@Valid @RequestBody body: CreateGroupRequest): GroupSummary = groupService.create(body)
|
||||
|
||||
@PostMapping("/{id}/students")
|
||||
fun createStudent(
|
||||
@PathVariable id: UUID,
|
||||
@Valid @RequestBody body: CreateStudentRequest,
|
||||
): Credential = groupService.createStudent(id, body)
|
||||
|
||||
@PostMapping("/import")
|
||||
fun import(
|
||||
@RequestParam("file") file: MultipartFile,
|
||||
|
||||
@@ -31,6 +31,44 @@ class GroupService(
|
||||
@Transactional(readOnly = true)
|
||||
fun list(): List<GroupSummary> = groups.findAllByOrderByNameAsc().map { summary(it) }
|
||||
|
||||
@Transactional
|
||||
fun createStudent(groupId: UUID, request: CreateStudentRequest): Credential {
|
||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||
val studentNo = request.studentNo.trim()
|
||||
if (studentNo.isBlank()) throw BadRequestException("Студенческий номер не может быть пустым")
|
||||
if (students.existsByStudentNo(studentNo)) {
|
||||
throw ConflictException("Студент с номером $studentNo уже существует")
|
||||
}
|
||||
val password = Passwords.generate()
|
||||
val account = accounts.save(
|
||||
Account(
|
||||
login = studentNo,
|
||||
passwordHash = passwordEncoder.encode(password) ?: "",
|
||||
role = LmsRole.STUDENT,
|
||||
mustChangePassword = true,
|
||||
tempPassword = password,
|
||||
),
|
||||
)
|
||||
val student = students.save(
|
||||
Student(
|
||||
studentNo = studentNo,
|
||||
lastName = request.lastName.trim(),
|
||||
firstName = request.firstName.trim(),
|
||||
middleName = request.middleName.trim(),
|
||||
basis = request.basis.trim(),
|
||||
note = request.note.trim(),
|
||||
),
|
||||
)
|
||||
student.account = account
|
||||
enrollments.save(Enrollment(group = group, student = student))
|
||||
return Credential(
|
||||
id = account.id.toString(),
|
||||
studentNo = studentNo,
|
||||
fullName = student.fullName,
|
||||
password = password,
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun create(request: CreateGroupRequest): GroupSummary {
|
||||
val name = request.name.trim()
|
||||
|
||||
@@ -14,6 +14,17 @@ data class CreateGroupRequest(
|
||||
val name: String,
|
||||
)
|
||||
|
||||
data class CreateStudentRequest(
|
||||
@field:NotBlank(message = "Укажите студенческий номер")
|
||||
val studentNo: String,
|
||||
@field:NotBlank(message = "Укажите фамилию")
|
||||
val lastName: String,
|
||||
val firstName: String = "",
|
||||
val middleName: String = "",
|
||||
val basis: String = "",
|
||||
val note: String = "",
|
||||
)
|
||||
|
||||
data class StudentRow(
|
||||
val id: String,
|
||||
val studentNo: String,
|
||||
|
||||
Reference in New Issue
Block a user