Build: enable ktlint+detekt, format sources, add core unit tests

This commit is contained in:
2026-09-27 21:46:05 +03:00
parent d35d345059
commit c6e0b0d7c8
103 changed files with 3834 additions and 1909 deletions

View File

@@ -8,16 +8,25 @@ import java.nio.file.Path
import java.util.UUID
@Component
class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") root: String) {
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)
data class StoredAsset(
val publicName: String,
val storedPath: Path,
val mime: String,
)
fun store(originalName: String, bytes: ByteArray): StoredAsset {
fun store(
originalName: String,
bytes: ByteArray,
): StoredAsset {
val ext = originalName.substringAfterLast('.', "").lowercase()
val isVideo = ext in VIDEO_EXTENSIONS
val isImage = ext in IMAGE_EXTENSIONS
@@ -50,22 +59,23 @@ class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") roo
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"
"mp4" -> "video/mp4"
"webm" -> "video/webm"
"ogg", "ogv" -> "video/ogg"
else -> "application/octet-stream"
}
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"
"mp4" -> "video/mp4"
"webm" -> "video/webm"
"ogg", "ogv" -> "video/ogg"
else -> "application/octet-stream"
}
private companion object {
val IMAGE_EXTENSIONS = setOf("png", "jpg", "jpeg", "gif", "svg", "webp")
val VIDEO_EXTENSIONS = setOf("mp4", "webm", "ogg", "ogv")
const val MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10 МБ на картинку
const val MAX_VIDEO_BYTES = 100 * 1024 * 1024 // 100 МБ на видео
const val MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10 МБ на картинку
const val MAX_VIDEO_BYTES = 100 * 1024 * 1024 // 100 МБ на видео
}
}

View File

@@ -40,7 +40,9 @@ data class ChapterTreeNode(
val sections: List<SectionTreeNode>,
)
data class ContentTree(val chapters: List<ChapterTreeNode>)
data class ContentTree(
val chapters: List<ChapterTreeNode>,
)
data class ContentArticleView(
val id: String,
@@ -68,16 +70,33 @@ data class ContentImportResult(
* (и перенос разделов между главами), порядок статей внутри разделов
* (и перенос статей между разделами). Применяется одним запросом.
*/
data class ReorderArticle(val id: String)
data class ReorderSection(val id: String, val articles: List<ReorderArticle> = emptyList())
data class ReorderChapter(val id: String, val sections: List<ReorderSection> = emptyList())
data class ReorderTree(val chapters: List<ReorderChapter> = emptyList())
data class ReorderArticle(
val id: String,
)
data class ReorderSection(
val id: String,
val articles: List<ReorderArticle> = emptyList(),
)
data class ReorderChapter(
val id: String,
val sections: List<ReorderSection> = emptyList(),
)
data class ReorderTree(
val chapters: List<ReorderChapter> = emptyList(),
)
/** Порядок разделов внутри одной главы (drag & drop на странице главы). */
data class ReorderSections(val sections: List<ReorderSection> = emptyList())
data class ReorderSections(
val sections: List<ReorderSection> = emptyList(),
)
/** Порядок статей внутри одного раздела (drag & drop на странице раздела). */
data class ReorderArticles(val articles: List<ReorderArticle> = emptyList())
data class ReorderArticles(
val articles: List<ReorderArticle> = emptyList(),
)
private data class ParsedArticle(
val path: String,
@@ -98,16 +117,19 @@ class ContentService(
private val articles: ContentArticleRepository,
private val assets: AssetStorage,
) {
@Transactional(readOnly = true)
fun tree(includeHidden: Boolean = false): ContentTree {
val chapterList = chapters.findAllByOrderByPositionAscIdAsc().filter { includeHidden || it.published }
val sectionList = sections.findAllByOrderByPositionAscIdAsc()
.filter { includeHidden || it.published }
.groupBy { it.chapterId }
val articleList = articles.findAllByOrderByPositionAscIdAsc()
.filter { includeHidden || it.published }
.groupBy { it.sectionId }
val sectionList =
sections
.findAllByOrderByPositionAscIdAsc()
.filter { includeHidden || it.published }
.groupBy { it.chapterId }
val articleList =
articles
.findAllByOrderByPositionAscIdAsc()
.filter { includeHidden || it.published }
.groupBy { it.sectionId }
return ContentTree(
chapterList.map { ch ->
ChapterTreeNode(
@@ -116,18 +138,20 @@ class ContentService(
slug = ch.slug,
position = ch.position,
published = ch.published,
sections = (sectionList[ch.id] ?: emptyList()).map { sec ->
SectionTreeNode(
id = sec.id.toString(),
title = sec.title,
slug = sec.slug,
position = sec.position,
published = sec.published,
articles = (articleList[sec.id] ?: emptyList()).map { a ->
ArticleTreeNode(a.id.toString(), a.title, a.slug, a.position, a.published)
},
)
},
sections =
(sectionList[ch.id] ?: emptyList()).map { sec ->
SectionTreeNode(
id = sec.id.toString(),
title = sec.title,
slug = sec.slug,
position = sec.position,
published = sec.published,
articles =
(articleList[sec.id] ?: emptyList()).map { a ->
ArticleTreeNode(a.id.toString(), a.title, a.slug, a.position, a.published)
},
)
},
)
},
)
@@ -167,21 +191,24 @@ class ContentService(
@Transactional
fun reorder(tree: ReorderTree) {
tree.chapters.forEachIndexed { chapterIndex, chapterDto ->
val chapter = chapters.findById(uuidOrNull(chapterDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
val chapter =
chapters.findById(uuidOrNull(chapterDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
chapter.position = chapterIndex + 1
chapters.save(chapter)
chapterDto.sections.forEachIndexed { sectionIndex, sectionDto ->
val section = sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
val section =
sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
section.chapterId = chapter.id
section.position = sectionIndex + 1
sections.save(section)
sectionDto.articles.forEachIndexed { articleIndex, articleDto ->
val article = articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
val article =
articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
article.sectionId = section.id
article.position = articleIndex + 1
articles.save(article)
@@ -192,17 +219,22 @@ class ContentService(
/** Порядок разделов внутри главы и статей внутри них (drag & drop на странице главы). */
@Transactional
fun reorderSections(chapterId: UUID, tree: List<ReorderSection>) {
fun reorderSections(
chapterId: UUID,
tree: List<ReorderSection>,
) {
tree.forEachIndexed { sectionIndex, sectionDto ->
val section = sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
val section =
sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
if (section.chapterId != chapterId) return@forEachIndexed
section.position = sectionIndex + 1
sections.save(section)
sectionDto.articles.forEachIndexed { articleIndex, articleDto ->
val article = articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
val article =
articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
article.sectionId = section.id
article.position = articleIndex + 1
articles.save(article)
@@ -212,35 +244,47 @@ class ContentService(
/** Порядок статей внутри одного раздела (drag & drop на странице раздела). */
@Transactional
fun reorderArticles(sectionId: UUID, tree: List<ReorderArticle>) {
fun reorderArticles(
sectionId: UUID,
tree: List<ReorderArticle>,
) {
tree.forEachIndexed { articleIndex, articleDto ->
val article = articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
val article =
articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
if (article.sectionId != sectionId) return@forEachIndexed
article.position = articleIndex + 1
articles.save(article)
}
}
private fun uuidOrNull(value: String): UUID? =
runCatching { UUID.fromString(value) }.getOrNull()
private fun uuidOrNull(value: String): UUID? = runCatching { UUID.fromString(value) }.getOrNull()
@Transactional
fun setChapterPublished(chapterId: UUID, published: Boolean) {
fun setChapterPublished(
chapterId: UUID,
published: Boolean,
) {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
ch.published = published
chapters.save(ch)
}
@Transactional
fun setSectionPublished(sectionId: UUID, published: Boolean) {
fun setSectionPublished(
sectionId: UUID,
published: Boolean,
) {
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
sec.published = published
sections.save(sec)
}
@Transactional
fun setArticlePublished(articleId: UUID, published: Boolean) {
fun setArticlePublished(
articleId: UUID,
published: Boolean,
) {
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
a.published = published
articles.save(a)
@@ -267,7 +311,11 @@ class ContentService(
}
@Transactional
fun updateArticle(articleId: UUID, title: String, bodyMd: String) {
fun updateArticle(
articleId: UUID,
title: String,
bodyMd: String,
) {
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
a.title = title.trim()
@@ -282,20 +330,31 @@ class ContentService(
}
@Transactional
fun createChapter(title: String, slug: String?): ChapterTreeNode {
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 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(
id = ch.id.toString(), title = ch.title, slug = ch.slug,
position = ch.position, published = ch.published, sections = emptyList(),
id = ch.id.toString(),
title = ch.title,
slug = ch.slug,
position = ch.position,
published = ch.published,
sections = emptyList(),
)
}
@Transactional
fun renameChapter(chapterId: UUID, title: String) {
fun renameChapter(
chapterId: UUID,
title: String,
) {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
ch.title = title.trim()
@@ -309,24 +368,37 @@ class ContentService(
}
@Transactional
fun createSection(chapterId: UUID, title: String, slug: String?): SectionTreeNode {
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 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),
)
val sec =
sections.save(
ContentSection(chapterId = ch.id, title = title.trim(), slug = cleanSlug, position = maxPos + 1),
)
return SectionTreeNode(
id = sec.id.toString(), title = sec.title, slug = sec.slug,
position = sec.position, published = sec.published, articles = emptyList(),
id = sec.id.toString(),
title = sec.title,
slug = sec.slug,
position = sec.position,
published = sec.published,
articles = emptyList(),
)
}
@Transactional
fun renameSection(sectionId: UUID, title: String) {
fun renameSection(
sectionId: UUID,
title: String,
) {
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
sec.title = title.trim()
@@ -341,7 +413,10 @@ class ContentService(
/** Переносит все статьи раздела в другой и удаляет раздел. */
@Transactional
fun moveArticlesAndDeleteSection(sectionId: UUID, targetSectionId: UUID) {
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("Раздел назначения совпадает с исходным")
@@ -358,58 +433,88 @@ class ContentService(
/** Создаёт статью; если раздела нет — создаёт главу/раздел по названиям. */
@Transactional
fun createArticlePlaced(title: String, bodyMd: String, chapterId: UUID?, newChapterTitle: String?,
sectionId: UUID?, newSectionTitle: String?): ContentArticleView {
val targetSection: ContentSection = when {
sectionId != null && !newSectionTitle.isNullOrBlank() -> {
val anchor = sections.findById(sectionId)
.orElseThrow { NotFoundException("Раздел не найден") }
val node = createSection(anchor.chapterId, newSectionTitle, null)
sections.findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Раздел не найден") }
}
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("Укажите главу")
fun createArticlePlaced(
title: String,
bodyMd: String,
chapterId: UUID?,
newChapterTitle: String?,
sectionId: UUID?,
newSectionTitle: String?,
): ContentArticleView {
val targetSection: ContentSection =
when {
sectionId != null && !newSectionTitle.isNullOrBlank() -> {
val anchor =
sections
.findById(sectionId)
.orElseThrow { NotFoundException("Раздел не найден") }
val node = createSection(anchor.chapterId, newSectionTitle, null)
sections
.findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Раздел не найден") }
}
val node = createSection(chapter.id, newSectionTitle, null)
sections.findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Раздел не найден") }
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("Укажите раздел")
}
else -> throw BadRequestException("Укажите раздел")
}
return createArticle(targetSection.id, title, bodyMd)
}
@Transactional
fun createArticle(sectionId: UUID, title: String, bodyMd: String): ContentArticleView {
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),
)
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,
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 {
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("Глава не найдена") }
@@ -418,13 +523,21 @@ class ContentService(
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,
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 {
private fun uniqueSlug(
title: String,
taken: Set<String>,
): String {
val base = slugify(title) ?: "item"
if (base !in taken) return base
var i = 2
@@ -438,7 +551,11 @@ class ContentService(
* картинки в assets/.
*/
@Transactional
fun importZip(filename: String, bytes: ByteArray, sync: Boolean = true): ContentImportResult {
fun importZip(
filename: String,
bytes: ByteArray,
sync: Boolean = true,
): ContentImportResult {
if (!filename.lowercase().endsWith(".zip")) {
throw BadRequestException("Ожидается .zip архив")
}
@@ -446,9 +563,10 @@ class ContentService(
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")
}
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>()
@@ -459,36 +577,38 @@ class ContentService(
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 },
),
)
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
@@ -523,15 +643,21 @@ class ContentService(
val chapter = currentChapter ?: throw BadRequestException("Не удалось определить главу")
if (currentSection?.title != p.sectionDir) {
val slug = sectionSlug(p) ?: "section-${secCount + 1}"
currentSection = (sections.findByChapterIdAndTitleIgnoreCase(chapter.id, p.sectionDir)
?: sections.findByChapterIdAndSlug(chapter.id, slug))?.apply {
currentSection = (
sections.findByChapterIdAndTitleIgnoreCase(chapter.id, p.sectionDir)
?: sections.findByChapterIdAndSlug(chapter.id, slug)
)?.apply {
title = p.sectionDir
position = p.sectionPos
} ?: sections.save(
ContentSection(
chapterId = chapter.id,
title = p.sectionDir,
slug = uniqueSlug(p.sectionDir, sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id).map { it.slug }.toSet()),
slug =
uniqueSlug(
p.sectionDir,
sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id).map { it.slug }.toSet(),
),
position = p.sectionPos,
),
)
@@ -545,16 +671,19 @@ class ContentService(
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.findBySectionIdAndTitleIgnoreCase(section.id, p.fileTitle)
?: articles.findBySectionIdAndSlug(section.id, slug)
val article = (existing ?: ContentArticle(sectionId = section.id, slug = slug)).apply {
title = p.fileTitle
bodyMd = body
position = p.articlePos
}
val slug =
p.fmSlug?.takeIf { it.isNotBlank() }
?: articleSlug(p)
?: "article-${artCount + 1}"
val existing =
articles.findBySectionIdAndTitleIgnoreCase(section.id, p.fileTitle)
?: 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)
seenArticles.add(article.id)
artCount += 1
@@ -613,19 +742,22 @@ class ContentService(
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 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
}
val unquoted =
if (v.length >= 2 && v.first() == '"' && v.last() == '"') {
v.substring(1, v.length - 1)
} else {
v
}
fm[parts[0].trim()] = unquoted
}
}
@@ -638,29 +770,61 @@ class ContentService(
}
private fun titleFromDir(dir: String): String {
val raw = dir.substringAfterLast('/')
.replace(Regex("^\\d{1,3}[-_]"), "")
.replace('_', ' ')
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('-')
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",
)
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",
)
}
}

View File

@@ -23,9 +23,14 @@ import java.util.UUID
@RestController
@RequestMapping("/api/v1/content")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
class ContentAdminController(private val contentService: ContentService) {
class ContentAdminController(
private val contentService: ContentService,
) {
data class ArticleUpdatePayload(
val title: String,
val bodyMd: String,
)
data class ArticleUpdatePayload(val title: String, val bodyMd: String)
data class ArticleCreatePayload(
val chapterId: String? = null,
val sectionId: String? = null,
@@ -34,11 +39,28 @@ class ContentAdminController(private val contentService: ContentService) {
val title: String,
val bodyMd: String = "",
)
data class ArticleMovePayload(val sectionId: String)
data class TitlePayload(val title: String)
data class PublishedPayload(val published: Boolean)
data class ChapterCreatePayload(val title: String, val slug: String? = null)
data class SectionCreatePayload(val title: String, val slug: String? = null)
data class ArticleMovePayload(
val sectionId: String,
)
data class TitlePayload(
val title: String,
)
data class PublishedPayload(
val published: Boolean,
)
data class ChapterCreatePayload(
val title: String,
val slug: String? = null,
)
data class SectionCreatePayload(
val title: String,
val slug: String? = null,
)
/** Полное дерево материалов, включая скрытые (для админки). */
@GetMapping("/tree/all")
@@ -53,42 +75,65 @@ class ContentAdminController(private val contentService: ContentService) {
): ContentPublicController.ContentArticleViewDto {
val a = contentService.findArticle(chapter, section, article, includeHidden = true)
return ContentPublicController.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, published = a.published,
id = a.id,
chapterSlug = a.chapterSlug,
chapterTitle = a.chapterTitle,
sectionSlug = a.sectionSlug,
sectionTitle = a.sectionTitle,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
published = a.published,
)
}
@PutMapping("/chapters/{id}/published")
fun chapterPublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
fun chapterPublished(
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
) {
contentService.setChapterPublished(id, body.published)
}
@PutMapping("/sections/{id}/published")
fun sectionPublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
fun sectionPublished(
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
) {
contentService.setSectionPublished(id, body.published)
}
@PutMapping("/articles/{id}/published")
fun articlePublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
fun articlePublished(
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
) {
contentService.setArticlePublished(id, body.published)
}
/** Новое расположение материалов (drag & drop глав, разделов и статей). */
@PostMapping("/reorder")
fun reorder(@RequestBody body: ReorderTree) {
fun reorder(
@RequestBody body: ReorderTree,
) {
contentService.reorder(body)
}
/** Порядок разделов внутри главы (drag & drop на странице главы). */
@PostMapping("/chapters/{id}/sections/reorder")
fun reorderSections(@PathVariable id: UUID, @RequestBody body: ReorderSections) {
fun reorderSections(
@PathVariable id: UUID,
@RequestBody body: ReorderSections,
) {
contentService.reorderSections(id, body.sections)
}
/** Порядок статей внутри раздела (drag & drop на странице раздела). */
@PostMapping("/sections/{id}/articles/reorder")
fun reorderArticles(@PathVariable id: UUID, @RequestBody body: ReorderArticles) {
fun reorderArticles(
@PathVariable id: UUID,
@RequestBody body: ReorderArticles,
) {
contentService.reorderArticles(id, body.articles)
}
@@ -105,25 +150,35 @@ class ContentAdminController(private val contentService: ContentService) {
fun list() = contentService.listAll()
@PostMapping("/chapters")
fun createChapter(@RequestBody body: ChapterCreatePayload) = contentService.createChapter(body.title, body.slug)
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)
fun renameChapter(
@PathVariable id: UUID,
@RequestBody body: TitlePayload,
) = contentService.renameChapter(id, body.title)
@DeleteMapping("/chapters/{id}")
fun deleteChapter(@PathVariable id: UUID): ResponseEntity<Void> {
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)
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)
fun renameSection(
@PathVariable id: UUID,
@RequestBody body: TitlePayload,
) = contentService.renameSection(id, body.title)
@DeleteMapping("/sections/{id}")
fun deleteSection(
@@ -139,7 +194,9 @@ class ContentAdminController(private val contentService: ContentService) {
}
@PostMapping("/articles")
fun createArticle(@RequestBody body: ArticleCreatePayload) = contentService.createArticlePlaced(
fun createArticle(
@RequestBody body: ArticleCreatePayload,
) = contentService.createArticlePlaced(
title = body.title,
bodyMd = body.bodyMd,
chapterId = body.chapterId?.let { UUID.fromString(it) },
@@ -149,16 +206,23 @@ class ContentAdminController(private val contentService: ContentService) {
)
@PutMapping("/articles/{id}")
fun update(@PathVariable id: UUID, @RequestBody body: ArticleUpdatePayload) {
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))
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> {
fun delete(
@PathVariable id: UUID,
): ResponseEntity<Void> {
contentService.deleteArticle(id)
return ResponseEntity.noContent().build()
}

View File

@@ -9,7 +9,6 @@ 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
@@ -18,7 +17,6 @@ class ContentPublicController(
private val contentService: ContentService,
private val assetStorage: AssetStorage,
) {
@GetMapping("/tree")
fun tree() = contentService.tree()
@@ -30,9 +28,15 @@ class ContentPublicController(
): 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, published = a.published,
id = a.id,
chapterSlug = a.chapterSlug,
chapterTitle = a.chapterTitle,
sectionSlug = a.sectionSlug,
sectionTitle = a.sectionTitle,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
published = a.published,
)
}
@@ -49,13 +53,19 @@ class ContentPublicController(
)
@GetMapping("/assets/{name}")
fun asset(@PathVariable name: String): ResponseEntity<FileSystemResource> {
val (path, mime) = assetStorage.read(name)
?: return ResponseEntity.notFound().build()
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()
return ResponseEntity
.ok()
.contentType(media)
.cacheControl(org.springframework.http.CacheControl.maxAge(java.time.Duration.ofDays(7)))
.body(FileSystemResource(path))
.cacheControl(
org.springframework.http.CacheControl
.maxAge(java.time.Duration.ofDays(7)),
).body(FileSystemResource(path))
}
}
}

View File

@@ -1,46 +1,72 @@
package ru.ysu.lms.academic.controller
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import jakarta.validation.Valid
import ru.ysu.lms.academic.lab.*
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.lab.LabCreateRequest
import ru.ysu.lms.academic.lab.LabDetail
import ru.ysu.lms.academic.lab.LabImportResult
import ru.ysu.lms.academic.lab.LabService
import ru.ysu.lms.academic.lab.LabSummary
import ru.ysu.lms.academic.lab.LabUpdateRequest
import ru.ysu.lms.academic.lab.LevelRenameRequest
import ru.ysu.lms.academic.lab.LevelSaveRequest
import ru.ysu.lms.academic.lab.VariantSaveRequest
import ru.ysu.lms.common.BadRequestException
import java.util.UUID
@RestController
@RequestMapping("/api/v1/labs")
class LabController(private val labService: LabService) {
class LabController(
private val labService: LabService,
) {
@GetMapping
fun list(): List<LabSummary> = labService.list()
@GetMapping("/{id}")
fun detail(@PathVariable id: UUID): LabDetail = labService.detail(id)
fun detail(
@PathVariable id: UUID,
): LabDetail = labService.detail(id)
@PostMapping("/import")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun importLab(@RequestParam("file") file: MultipartFile): LabImportResult {
fun importLab(
@RequestParam("file") file: MultipartFile,
): LabImportResult {
if (file.isEmpty) throw BadRequestException("Выберите файл")
return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes)
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun delete(@PathVariable id: UUID) {
fun delete(
@PathVariable id: UUID,
) {
labService.delete(id)
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun update(@PathVariable id: UUID, @Valid @RequestBody body: LabUpdateRequest): LabDetail =
labService.update(id, body)
fun update(
@PathVariable id: UUID,
@Valid @RequestBody body: LabUpdateRequest,
): LabDetail = labService.update(id, body)
@PostMapping("/{id}/variants")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun addVariant(@PathVariable id: UUID, @Valid @RequestBody body: VariantSaveRequest): LabDetail =
labService.addVariant(id, body)
fun addVariant(
@PathVariable id: UUID,
@Valid @RequestBody body: VariantSaveRequest,
): LabDetail = labService.addVariant(id, body)
@PutMapping("/{id}/variants/{variantId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
@@ -52,22 +78,31 @@ class LabController(private val labService: LabService) {
@DeleteMapping("/{id}/variants/{variantId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun deleteVariant(@PathVariable id: UUID, @PathVariable variantId: UUID): LabDetail =
labService.deleteVariant(id, variantId)
fun deleteVariant(
@PathVariable id: UUID,
@PathVariable variantId: UUID,
): LabDetail = labService.deleteVariant(id, variantId)
@PostMapping("/{id}/toggle-status")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun toggleStatus(@PathVariable id: UUID) {
fun toggleStatus(
@PathVariable id: UUID,
) {
labService.toggleStatus(id)
}
@PostMapping
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun create(@Valid @RequestBody body: LabCreateRequest): LabDetail = labService.create(body)
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 {
fun addLevel(
@PathVariable id: UUID,
@Valid @RequestBody body: LevelSaveRequest,
): LabDetail {
labService.addLevel(id, body)
return labService.detail(id)
}
@@ -82,6 +117,8 @@ class LabController(private val labService: LabService) {
@DeleteMapping("/{id}/levels/{levelId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun deleteLevel(@PathVariable id: UUID, @PathVariable levelId: UUID): LabDetail =
labService.deleteLevel(id, levelId)
}
fun deleteLevel(
@PathVariable id: UUID,
@PathVariable levelId: UUID,
): LabDetail = labService.deleteLevel(id, levelId)
}

View File

@@ -38,4 +38,4 @@ class ContentArticle(
@Column(nullable = false) var bodyMd: String = "",
@Column(nullable = false) var position: Int = 0,
@Column(nullable = false) var published: Boolean = true,
)
)

View File

@@ -6,8 +6,6 @@ import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.OneToMany
import jakarta.persistence.OrderBy
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
@@ -37,4 +35,4 @@ class Lab(
var status: String = "draft",
@Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(),
)
)

View File

@@ -28,4 +28,4 @@ class LabLevel(
var position: Int = 0,
@Column(name = "points_max", columnDefinition = "numeric(4,1)")
var pointsMax: java.math.BigDecimal? = null,
)
)

View File

@@ -18,4 +18,4 @@ class Semester(
var year: Int = 0,
@Column(nullable = false)
var active: Boolean = false,
)
)

View File

@@ -29,4 +29,4 @@ class Variant(
var statementMd: String = "",
@Column(nullable = false)
var position: Int = 0,
)
)

View File

@@ -119,4 +119,4 @@ data class LabCreateRequest(
data class LevelRenameRequest(
val title: String,
val description: String? = null,
)
)

View File

@@ -16,12 +16,16 @@ import java.util.zip.ZipInputStream
*/
@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 yaml =
ObjectMapper(YAMLFactory())
.registerModule(KotlinModule.Builder().build())
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
fun parse(filename: String, bytes: ByteArray): LabImportBundle {
fun parse(
filename: String,
bytes: ByteArray,
): LabImportBundle {
if (!filename.lowercase().endsWith(".zip")) {
throw BadRequestException("Формат импорта — .zip (lab.yaml + README.md + variants/)")
}
@@ -40,8 +44,9 @@ class LabImportParser {
}
}
val metaEntry = entries.entries.firstOrNull { it.key.endsWith("lab.yaml") || it.key.endsWith("lab.yml") }
?: throw BadRequestException("В архиве не найден lab.yaml")
val metaEntry =
entries.entries.firstOrNull { it.key.endsWith("lab.yaml") || it.key.endsWith("lab.yml") }
?: throw BadRequestException("В архиве не найден lab.yaml")
val meta = yaml.readValue(metaEntry.value, LabMeta::class.java)
val readmeEntry = entries.entries.firstOrNull { it.key.endsWith("/README.md") || it.key == "README.md" }
@@ -71,7 +76,11 @@ class LabImportParser {
return LabImportBundle(meta, readmeMd, variants, assets)
}
private fun parseVariantFile(path: String, content: String, levels: List<LabLevelMeta>): VariantMeta? {
private fun parseVariantFile(
path: String,
content: String,
levels: List<LabLevelMeta>,
): VariantMeta? {
val levelMap = levels.associateBy { it.code }
val lines = content.lines()
val firstSep = lines.indexOfFirst { it.trim() == "---" }
@@ -111,9 +120,10 @@ class LabImportParser {
value
}
private fun normalizeLevel(s: String): String = when (s.lowercase().trim()) {
"main", "basic", "обычная", "основная" -> "basic"
"advanced", "повышенная", "повышенной", "hard" -> "advanced"
else -> s.lowercase().trim()
}
}
private fun normalizeLevel(s: String): String =
when (s.lowercase().trim()) {
"main", "basic", "обычная", "основная" -> "basic"
"advanced", "повышенная", "повышенной", "hard" -> "advanced"
else -> s.lowercase().trim()
}
}

View File

@@ -10,9 +10,9 @@ import ru.ysu.lms.academic.repo.LabLevelRepository
import ru.ysu.lms.academic.repo.LabRepository
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 ru.ysu.lms.common.NotFoundException
import java.math.BigDecimal
import java.time.Instant
import java.time.LocalDateTime
@@ -28,12 +28,15 @@ class LabService(
private val semesters: SemesterRepository,
private val parser: LabImportParser,
) {
@Transactional
fun importFromFile(filename: String, bytes: ByteArray): LabImportResult {
fun importFromFile(
filename: String,
bytes: ByteArray,
): LabImportResult {
val bundle = parser.parse(filename, bytes)
val semester = semesters.findFirstByActiveTrue()
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
val semester =
semesters.findFirstByActiveTrue()
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
if (labs.existsByNumberAndSemesterId(bundle.meta.number, semester.id)) {
throw ConflictException(
@@ -41,46 +44,56 @@ class LabService(
)
}
val lab = labs.save(Lab(
number = bundle.meta.number,
title = bundle.meta.title,
semester = semester,
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 {
listOf(
LabLevelMeta("basic", "Основная группа", position = 1),
LabLevelMeta("advanced", "Повышенная сложность", position = 2),
val lab =
labs.save(
Lab(
number = bundle.meta.number,
title = bundle.meta.title,
semester = semester,
descriptionMd = bundle.descriptionMd,
pointsMax = bundle.meta.pointsMax,
status = bundle.meta.status,
deadlineAt = parseInstant(bundle.meta.deadlineAt),
defenseAt = parseInstant(bundle.meta.defenseAt),
),
)
}
val levelEntities = levelMetas.map { meta ->
labLevels.save(LabLevel(
lab = lab,
code = meta.code,
title = meta.title,
description = meta.description.orEmpty(),
position = meta.position ?: 0,
pointsMax = meta.pointsMax,
))
}
val levelMetas =
bundle.meta.levels.ifEmpty {
listOf(
LabLevelMeta("basic", "Основная группа", position = 1),
LabLevelMeta("advanced", "Повышенная сложность", position = 2),
)
}
val levelEntities =
levelMetas.map { meta ->
labLevels.save(
LabLevel(
lab = lab,
code = meta.code,
title = meta.title,
description = meta.description.orEmpty(),
position = meta.position ?: 0,
pointsMax = meta.pointsMax,
),
)
}
val levelByCode = levelEntities.associateBy { it.code }
bundle.variants.forEach { vmeta ->
val level = levelByCode[vmeta.level]
?: throw BadRequestException("Неизвестный уровень ${vmeta.level} у варианта ${vmeta.number}")
variantsRepo.save(Variant(
lab = lab,
labLevel = level,
number = vmeta.number,
title = vmeta.title,
statementMd = vmeta.statement,
))
val level =
levelByCode[vmeta.level]
?: throw BadRequestException("Неизвестный уровень ${vmeta.level} у варианта ${vmeta.number}")
variantsRepo.save(
Variant(
lab = lab,
labLevel = level,
number = vmeta.number,
title = vmeta.title,
statementMd = vmeta.statement,
),
)
}
return LabImportResult(
@@ -122,25 +135,29 @@ class LabService(
deadlineAt = lab.deadlineAt?.toString(),
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,
title = level.title,
variants = vs.map { v ->
VariantDetail(
id = v.id.toString(),
number = v.number,
title = v.title,
statementMd = v.statementMd,
)
},
)
},
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,
title = level.title,
variants =
vs.map { v ->
VariantDetail(
id = v.id.toString(),
number = v.number,
title = v.title,
statementMd = v.statementMd,
)
},
)
},
)
}
@@ -153,7 +170,10 @@ class LabService(
}
@Transactional
fun update(labId: UUID, request: LabUpdateRequest): LabDetail {
fun update(
labId: UUID,
request: LabUpdateRequest,
): LabDetail {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
if (request.title.isBlank()) throw BadRequestException("Название не может быть пустым")
lab.title = request.title.trim()
@@ -167,42 +187,62 @@ class LabService(
}
@Transactional
fun updateVariant(labId: UUID, variantId: UUID, request: VariantSaveRequest): LabDetail {
val variant = variantsRepo.findById(variantId)
.orElseThrow { NotFoundException("Вариант не найден") }
fun updateVariant(
labId: UUID,
variantId: UUID,
request: VariantSaveRequest,
): LabDetail {
val variant =
variantsRepo
.findById(variantId)
.orElseThrow { NotFoundException("Вариант не найден") }
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым")
variant.number = request.number.trim()
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("Уровень не найден") }
variant.labLevel =
labLevels
.findById(UUID.fromString(levelId))
.orElseThrow { BadRequestException("Уровень не найден") }
}
variantsRepo.save(variant)
return detail(labId)
}
@Transactional
fun addVariant(labId: UUID, request: VariantSaveRequest): LabDetail {
fun addVariant(
labId: UUID,
request: VariantSaveRequest,
): LabDetail {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
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.orEmpty().trim(),
statementMd = request.statementMd.orEmpty(),
))
val level =
labLevels
.findById(UUID.fromString(request.labLevelId))
.orElseThrow { BadRequestException("Уровень не найден") }
variantsRepo.save(
Variant(
lab = lab,
labLevel = level,
number = request.number.trim(),
title = request.title.orEmpty().trim(),
statementMd = request.statementMd.orEmpty(),
),
)
return detail(labId)
}
@Transactional
fun deleteVariant(labId: UUID, variantId: UUID): LabDetail {
val variant = variantsRepo.findById(variantId)
.orElseThrow { NotFoundException("Вариант не найден") }
fun deleteVariant(
labId: UUID,
variantId: UUID,
): LabDetail {
val variant =
variantsRepo
.findById(variantId)
.orElseThrow { NotFoundException("Вариант не найден") }
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
variantsRepo.delete(variant)
return detail(labId)
@@ -216,21 +256,23 @@ class LabService(
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 = "Основная группа"))
}
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(
@@ -247,7 +289,11 @@ class LabService(
}
@Transactional
fun renameLevel(labId: UUID, levelId: UUID, request: LevelRenameRequest): LabDetail {
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 }
@@ -257,7 +303,10 @@ class LabService(
}
@Transactional
fun deleteLevel(labId: UUID, levelId: UUID): LabDetail {
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 }) {
@@ -267,8 +316,11 @@ class LabService(
return detail(labId)
}
@Transactional
fun addLevel(labId: UUID, request: LevelSaveRequest) {
@Transactional
fun addLevel(
labId: UUID,
request: LevelSaveRequest,
) {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
if (request.code.isBlank()) throw BadRequestException("Код уровня не может быть пустым")
val existing = labLevels.findByLabIdOrderByPosition(labId)
@@ -290,15 +342,24 @@ class LabService(
labs.save(lab)
}
private fun findOrCreateSemester(title: String, year: Int): Semester {
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
val y =
if (year > 0) {
year
} else {
java.time.LocalDate
.now()
.year
}
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 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
@@ -307,4 +368,4 @@ class LabService(
.recoverCatching { LocalDateTime.parse(v).atZone(ZoneId.of("Europe/Moscow")).toInstant() }
.getOrNull()
}
}
}

View File

@@ -8,20 +8,40 @@ import java.util.UUID
interface ContentChapterRepository : JpaRepository<ContentChapter, UUID> {
fun findAllByOrderByPositionAscIdAsc(): List<ContentChapter>
fun findBySlug(slug: String): ContentChapter?
fun findByTitleIgnoreCase(title: String): ContentChapter?
}
interface ContentSectionRepository : JpaRepository<ContentSection, UUID> {
fun findAllByOrderByPositionAscIdAsc(): List<ContentSection>
fun findByChapterIdOrderByPositionAscIdAsc(chapterId: UUID): List<ContentSection>
fun findByChapterIdAndSlug(chapterId: UUID, slug: String): ContentSection?
fun findByChapterIdAndTitleIgnoreCase(chapterId: UUID, title: String): ContentSection?
fun findByChapterIdAndSlug(
chapterId: UUID,
slug: String,
): ContentSection?
fun findByChapterIdAndTitleIgnoreCase(
chapterId: UUID,
title: String,
): ContentSection?
}
interface ContentArticleRepository : JpaRepository<ContentArticle, UUID> {
fun findBySectionIdOrderByPositionAscIdAsc(sectionId: UUID): List<ContentArticle>
fun findBySectionIdAndSlug(sectionId: UUID, slug: String): ContentArticle?
fun findBySectionIdAndTitleIgnoreCase(sectionId: UUID, title: String): ContentArticle?
fun findBySectionIdAndSlug(
sectionId: UUID,
slug: String,
): ContentArticle?
fun findBySectionIdAndTitleIgnoreCase(
sectionId: UUID,
title: String,
): ContentArticle?
fun findAllByOrderByPositionAscIdAsc(): List<ContentArticle>
}
}

View File

@@ -6,5 +6,9 @@ import java.util.UUID
interface LabRepository : JpaRepository<Lab, UUID> {
fun findAllByOrderByNumberAsc(): List<Lab>
fun existsByNumberAndSemesterId(number: Int, semesterId: UUID): Boolean
}
fun existsByNumberAndSemesterId(
number: Int,
semesterId: UUID,
): Boolean
}

View File

@@ -6,4 +6,4 @@ import java.util.UUID
interface SemesterRepository : JpaRepository<Semester, UUID> {
fun findFirstByActiveTrue(): Semester?
}
}

View File

@@ -11,4 +11,4 @@ interface LabLevelRepository : JpaRepository<LabLevel, UUID> {
interface VariantRepository : JpaRepository<Variant, UUID> {
fun findByLabIdOrderByNumber(labId: UUID): List<Variant>
}
}

View File

@@ -8,7 +8,6 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.jwt.JwtDecoder
@@ -20,18 +19,19 @@ import org.springframework.security.web.SecurityFilterChain
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {
@Bean
fun jwtDecoder(): JwtDecoder =
NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
@Bean
fun jwtAuthenticationConverter(): Converter<Jwt, out AbstractAuthenticationToken> {
val converter = JwtAuthenticationConverter()
converter.setJwtGrantedAuthoritiesConverter { jwt ->
val role = jwt.getClaimAsString("role")
if (role.isNullOrBlank()) emptyList()
else listOf(SimpleGrantedAuthority("ROLE_$role"))
if (role.isNullOrBlank()) {
emptyList()
} else {
listOf(SimpleGrantedAuthority("ROLE_$role"))
}
}
return converter
}
@@ -43,17 +43,17 @@ class SecurityConfig {
.cors { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
auth.requestMatchers(
"/actuator/**",
"/v3/api-docs/**",
"/swagger-ui/**",
"/api/v1/content/tree",
"/api/v1/content/assets/**",
"/api/v1/content/{chapter}/{section}/{article}",
).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 ->
}.oauth2ResourceServer { oauth ->
oauth.jwt { jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
}
@@ -64,4 +64,4 @@ class SecurityConfig {
companion object {
private val JWK_URI = System.getenv("LMS_JWT_JWK_SET_URI") ?: "http://localhost:8081/api/v1/auth/jwks.json"
}
}
}