From 006951adaefb9046fc8cc5237d6c155a39a02a86 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Fri, 11 Sep 2026 16:00:56 +0300 Subject: [PATCH] UI rework: modal dialogs, inline lab editing, manual creation, content management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../ysu/lms/academic/content/AssetStorage.kt | 59 +++ .../lms/academic/content/ContentService.kt | 477 ++++++++++++++++++ .../controller/ContentAdminController.kt | 106 ++++ .../controller/ContentPublicController.kt | 60 +++ .../lms/academic/controller/LabController.kt | 24 + .../ru/ysu/lms/academic/domain/Content.kt | 38 ++ .../kotlin/ru/ysu/lms/academic/lab/LabDtos.kt | 35 +- .../ru/ysu/lms/academic/lab/LabService.kt | 106 +++- .../lms/academic/repo/ContentRepositories.kt | 24 + .../ru/ysu/lms/academic/repo/LabRepository.kt | 1 + .../lms/academic/security/SecurityConfig.kt | 9 +- .../src/main/resources/application.yml | 4 + .../resources/db/migration/V4__content.sql | 29 ++ .../security/JwtAuthenticationFilter.kt | 3 +- .../src/main/resources/application.yml | 4 +- .../lms/identity/roster/GroupController.kt | 6 + .../ysu/lms/identity/roster/GroupService.kt | 38 ++ .../ru/ysu/lms/identity/roster/RosterDtos.kt | 11 + .../ru/ysu/lms/web/admin/AdminController.kt | 140 ++++- .../lms/web/admin/ContentAdminController.kt | 216 ++++++++ .../kotlin/ru/ysu/lms/web/api/ApiClient.kt | 211 ++++++++ .../main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt | 99 ++++ .../ru/ysu/lms/web/auth/AuthPageController.kt | 14 +- .../ru/ysu/lms/web/config/CsrfInterceptor.kt | 43 ++ .../kotlin/ru/ysu/lms/web/config/WebConfig.kt | 2 + .../ru/ysu/lms/web/learn/ContentAssetProxy.kt | 26 + .../ru/ysu/lms/web/learn/LearnController.kt | 79 +++ web/src/main/resources/application.yml | 5 + web/src/main/resources/static/css/app.css | 21 + web/src/main/resources/static/favicon.svg | 7 + .../resources/templates/admin/dashboard.html | 53 +- .../main/resources/templates/admin/group.html | 42 +- .../resources/templates/admin/groups.html | 2 + .../resources/templates/admin/lab-edit.html | 4 + .../main/resources/templates/admin/lab.html | 211 +++++++- .../main/resources/templates/admin/labs.html | 55 +- .../resources/templates/change_password.html | 1 + .../resources/templates/fragments/base.html | 17 +- .../resources/templates/fragments/csrf.html | 0 .../resources/templates/learn/article.html | 18 + .../main/resources/templates/learn/index.html | 195 +++++++ .../resources/templates/learn/section.html | 23 + web/src/main/resources/templates/login.html | 1 + web/src/main/resources/templates/setup.html | 1 + 44 files changed, 2435 insertions(+), 85 deletions(-) create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/AssetStorage.kt create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentPublicController.kt create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Content.kt create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/ContentRepositories.kt create mode 100644 services/academic-service/src/main/resources/db/migration/V4__content.sql create mode 100644 web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt create mode 100644 web/src/main/kotlin/ru/ysu/lms/web/config/CsrfInterceptor.kt create mode 100644 web/src/main/kotlin/ru/ysu/lms/web/learn/ContentAssetProxy.kt create mode 100644 web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt create mode 100644 web/src/main/resources/static/favicon.svg create mode 100644 web/src/main/resources/templates/fragments/csrf.html create mode 100644 web/src/main/resources/templates/learn/article.html create mode 100644 web/src/main/resources/templates/learn/index.html create mode 100644 web/src/main/resources/templates/learn/section.html diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/AssetStorage.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/AssetStorage.kt new file mode 100644 index 0000000..39bfc0d --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/AssetStorage.kt @@ -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? { + 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 + } +} diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt new file mode 100644 index 0000000..7b1c0f5 --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt @@ -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, +) + +data class ChapterTreeNode( + val id: String, + val title: String, + val slug: String, + val position: Int, + val sections: List, +) + +data class ContentTree(val chapters: List) + +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 { + 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 { + 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() + 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 { + val result = mutableMapOf() + 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, String> { + if (!content.startsWith("---")) return emptyMap() to content + val lines = content.lines() + val end = lines.withIndex() + .firstOrNull { it.index != 0 && it.value.trim() == "---" } + ?.index ?: return emptyMap() to content + val fm = mutableMapOf() + 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", + ) + } +} diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt new file mode 100644 index 0000000..1163d94 --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt @@ -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 { + 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 { + 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 { + contentService.deleteArticle(id) + return ResponseEntity.noContent().build() + } +} diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentPublicController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentPublicController.kt new file mode 100644 index 0000000..77ec090 --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentPublicController.kt @@ -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 { + 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)) + } +} \ No newline at end of file diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt index 7806a6f..f733e86 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt @@ -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) } \ No newline at end of file diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Content.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Content.kt new file mode 100644 index 0000000..fc31b68 --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Content.kt @@ -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, +) \ No newline at end of file diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabDtos.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabDtos.kt index 6d00c43..2973fd5 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabDtos.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabDtos.kt @@ -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 = emptyList(), +) + +data class LevelRenameRequest( val title: String, - val statementMd: String, + val description: String? = null, ) \ No newline at end of file diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt index 157dd19..e829c15 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt @@ -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) } diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/ContentRepositories.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/ContentRepositories.kt new file mode 100644 index 0000000..7357d95 --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/ContentRepositories.kt @@ -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 { + fun findAllByOrderByPositionAscIdAsc(): List + fun findBySlug(slug: String): ContentChapter? +} + +interface ContentSectionRepository : JpaRepository { + fun findAllByOrderByPositionAscIdAsc(): List + fun findByChapterIdOrderByPositionAscIdAsc(chapterId: UUID): List + fun findByChapterIdAndSlug(chapterId: UUID, slug: String): ContentSection? +} + +interface ContentArticleRepository : JpaRepository { + fun findBySectionIdOrderByPositionAscIdAsc(sectionId: UUID): List + fun findBySectionIdAndSlug(sectionId: UUID, slug: String): ContentArticle? + fun findAllByOrderByPositionAscIdAsc(): List +} \ No newline at end of file diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/LabRepository.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/LabRepository.kt index e2fddee..01539fa 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/LabRepository.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/LabRepository.kt @@ -6,4 +6,5 @@ import java.util.UUID interface LabRepository : JpaRepository { fun findAllByOrderByNumberAsc(): List + fun existsByNumberAndSemesterId(number: Int, semesterId: UUID): Boolean } \ No newline at end of file diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/security/SecurityConfig.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/security/SecurityConfig.kt index c090677..f089480 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/security/SecurityConfig.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/security/SecurityConfig.kt @@ -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 -> diff --git a/services/academic-service/src/main/resources/application.yml b/services/academic-service/src/main/resources/application.yml index b89dd59..2084e5e 100644 --- a/services/academic-service/src/main/resources/application.yml +++ b/services/academic-service/src/main/resources/application.yml @@ -35,3 +35,7 @@ management: springdoc: api-docs: path: /v3/api-docs + +lms: + content: + assets-dir: ${LMS_CONTENT_ASSETS_DIR:data/content-assets} diff --git a/services/academic-service/src/main/resources/db/migration/V4__content.sql b/services/academic-service/src/main/resources/db/migration/V4__content.sql new file mode 100644 index 0000000..7f181bc --- /dev/null +++ b/services/academic-service/src/main/resources/db/migration/V4__content.sql @@ -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; \ No newline at end of file diff --git a/services/gateway/src/main/kotlin/ru/ysu/lms/gateway/security/JwtAuthenticationFilter.kt b/services/gateway/src/main/kotlin/ru/ysu/lms/gateway/security/JwtAuthenticationFilter.kt index 342d0d5..2d6c86d 100644 --- a/services/gateway/src/main/kotlin/ru/ysu/lms/gateway/security/JwtAuthenticationFilter.kt +++ b/services/gateway/src/main/kotlin/ru/ysu/lms/gateway/security/JwtAuthenticationFilter.kt @@ -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 diff --git a/services/gateway/src/main/resources/application.yml b/services/gateway/src/main/resources/application.yml index 6bedd04..ea7e9ff 100644 --- a/services/gateway/src/main/resources/application.yml +++ b/services/gateway/src/main/resources/application.yml @@ -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: diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt index 2b00567..8c00d76 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt @@ -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, diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt index ec5c156..44632ee 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt @@ -31,6 +31,44 @@ class GroupService( @Transactional(readOnly = true) fun list(): List = 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() diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt index 2d96d1c..c682854 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt @@ -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, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt index 9bf6002..9239875 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt @@ -16,9 +16,13 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.AssignVariantPayload +import ru.ysu.lms.web.api.CreateStudentRequestDto import ru.ysu.lms.web.api.GradeSetPayload +import ru.ysu.lms.web.api.LabCreatePayload import ru.ysu.lms.web.api.LabImportResultDto import ru.ysu.lms.web.api.LabUpdatePayload +import ru.ysu.lms.web.api.LevelPayload +import ru.ysu.lms.web.api.LevelSavePayload import ru.ysu.lms.web.api.VariantSavePayload import ru.ysu.lms.web.api.DistributeResultDto import ru.ysu.lms.web.api.LevelAssignPayload @@ -30,14 +34,31 @@ import java.nio.charset.StandardCharsets @Controller @RequestMapping("/admin") -class AdminController(private val api: ApiClient) { +class AdminController( + private val api: ApiClient, + private val markdown: ru.ysu.lms.markdown.MarkdownRenderer, +) { @GetMapping fun dashboard(session: HttpSession, model: Model): String { - val groups = api.listGroups(token(session)) + val t = token(session) + val groups = api.listGroups(t) + val labs = api.listLabs(t) + val grades = api.listGrades(t) model.addAttribute("groupCount", groups.size) model.addAttribute("studentCount", groups.sumOf { it.studentCount }) - model.addAttribute("groups", groups.take(5)) + model.addAttribute("groupCount", groups.size) + model.addAttribute("labCount", labs.size) + val gradedByStudent = grades.groupBy { it.studentId } + val averages = gradedByStudent.values.mapNotNull { rows -> + rows.mapNotNull { g -> g.score.toBigDecimalOrNull() } + .takeIf { it.isNotEmpty() } + ?.let { nums -> nums.map { it.toDouble() }.average() } + } + model.addAttribute("avgGrade", if (averages.isEmpty()) null else averages.average()) + model.addAttribute("publishedLabs", labs.count { it.status == "published" }) + model.addAttribute("labs", labs) + model.addAttribute("groups", groups) return "admin/dashboard" } @@ -247,6 +268,9 @@ class AdminController(private val api: ApiClient) { } model.addAttribute("lab", lab) model.addAttribute("groups", groupSections) + model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd)) + model.addAttribute("deadlineLocal", lab.deadlineAt?.take(16) ?: "") + model.addAttribute("defenseLocal", lab.defenseAt?.take(16) ?: "") return "admin/lab" } @@ -266,6 +290,116 @@ class AdminController(private val api: ApiClient) { "redirect:/admin/labs/$labId" } + @PostMapping("/labs/create") + fun createLab( + @RequestParam number: Int, + @RequestParam title: String, + @RequestParam(required = false) deadlineAt: String?, + @RequestParam(required = false) defenseAt: String?, + @RequestParam pointsMax: String, + @RequestParam status: String, + @RequestParam descriptionMd: String, + @RequestParam levels: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + // levels: строки "код;название" по одной на строку + val levelList = levels.lines() + .map { it.trim() } + .filter { it.isNotBlank() } + .mapIndexed { idx, line -> + val parts = line.split(";", limit = 2) + LevelPayload( + code = parts[0].trim(), + title = parts.getOrNull(1)?.trim().takeUnless { it.isNullOrBlank() } ?: parts[0].trim(), + position = idx + 1, + ) + } + val created = api.createLab( + token(session), + LabCreatePayload( + number = number, + title = title.trim(), + deadlineAt = deadlineAt?.takeIf { it.isNotBlank() }, + defenseAt = defenseAt?.takeIf { it.isNotBlank() }, + pointsMax = java.math.BigDecimal(pointsMax), + status = status, + descriptionMd = descriptionMd, + levels = levelList, + ), + ) + redirect.addFlashAttribute("notice", "Лабораторная №${created.number} создана") + "redirect:/admin/labs/${created.id}" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs" + } + + @PostMapping("/labs/{labId}/levels/save") + fun saveLevel( + @PathVariable labId: String, + @RequestParam(required = false) levelId: String?, + @RequestParam code: String, + @RequestParam title: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + if (levelId.isNullOrBlank()) { + api.addLevel(token(session), labId, LevelSavePayload(code, title)) + redirect.addFlashAttribute("notice", "Уровень добавлен") + } else { + api.renameLevel(token(session), labId, levelId, title) + redirect.addFlashAttribute("notice", "Уровень обновлён") + } + "redirect:/admin/labs/$labId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$labId" + } + + @PostMapping("/labs/{labId}/levels/{levelId}/delete") + fun deleteLevel( + @PathVariable labId: String, + @PathVariable levelId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.deleteLevel(token(session), labId, levelId) + redirect.addFlashAttribute("notice", "Уровень удалён") + "redirect:/admin/labs/$labId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$labId" + } + + @PostMapping("/groups/{groupId}/students/create") + fun createStudent( + @PathVariable groupId: String, + @RequestParam studentNo: String, + @RequestParam lastName: String, + @RequestParam(required = false) firstName: String?, + @RequestParam(required = false) middleName: String?, + @RequestParam(required = false) basis: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + val result = api.createStudent( + token(session), groupId, + CreateStudentRequestDto( + studentNo = studentNo.trim(), + lastName = lastName.trim(), + firstName = firstName.orEmpty().trim(), + middleName = middleName.orEmpty().trim(), + basis = basis.orEmpty().trim(), + ), + ) + redirect.addFlashAttribute("createdStudent", result) + "redirect:/admin/groups/$groupId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$groupId" + } + @GetMapping("/labs/{id}/edit") fun editLabForm( @PathVariable id: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt new file mode 100644 index 0000000..5345422 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt @@ -0,0 +1,216 @@ +package ru.ysu.lms.web.admin + +import jakarta.servlet.http.HttpSession +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +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.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.servlet.mvc.support.RedirectAttributes +import ru.ysu.lms.web.api.ApiClient +import ru.ysu.lms.web.api.ApiException +import ru.ysu.lms.web.session.SessionKeys + +@Controller +@RequestMapping("/admin/content") +class ContentAdminController( + private val api: ApiClient, +) { + + @GetMapping + fun index(session: HttpSession): String = "redirect:/learn" + + @PostMapping("/import") + fun import( + @RequestParam("file") file: MultipartFile, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + if (file.isEmpty) { + redirect.addFlashAttribute("error", "Выберите файл") + return "redirect:/admin/content" + } + return try { + val result = api.importContent(token(session), file.originalFilename ?: "content.zip", file.bytes) + redirect.addFlashAttribute( + "notice", + "Импортировано: глав ${result.chapters}, разделов ${result.sections}, статей ${result.articles}, картинок ${result.assets}", + ) + "redirect:/admin/content" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/content" + } + } + + @PostMapping("/articles/{id}/save") + fun save( + @PathVariable id: String, + @RequestParam title: String, + @RequestParam bodyMd: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.updateContentArticle(token(session), id, title, bodyMd) + redirect.addFlashAttribute("notice", "Статья сохранена") + "redirect:/admin/content" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/content/articles/$id/edit" + } + + @PostMapping("/articles/{id}/delete") + fun delete( + @PathVariable id: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.deleteContentArticle(token(session), id) + redirect.addFlashAttribute("notice", "Статья удалена") + "redirect:/admin/content" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/content" + } + + @PostMapping("/chapters/create") + fun createChapter( + @RequestParam title: String, + @RequestParam(required = false) slug: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.createChapter(token(session), title.trim(), slug) + redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана") + "redirect:/admin/content" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/content" + } + + @PostMapping("/chapters/{id}/rename") + fun renameChapter( + @PathVariable id: String, + @RequestParam title: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.renameChapter(token(session), id, title.trim()) + redirect.addFlashAttribute("notice", "Глава переименована") + "redirect:/admin/content" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/content" + } + + @PostMapping("/chapters/{id}/delete") + fun deleteChapter( + @PathVariable id: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.deleteChapter(token(session), id) + redirect.addFlashAttribute("notice", "Глава удалена вместе с содержимым") + "redirect:/admin/content" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/content" + } + + @PostMapping("/sections/create") + fun createSection( + @RequestParam chapterId: String, + @RequestParam title: String, + @RequestParam(required = false) slug: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.createSection(token(session), chapterId, title.trim(), slug) + redirect.addFlashAttribute("notice", "Раздел «${title.trim()}» создан") + "redirect:/admin/content" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/content" + } + + @PostMapping("/sections/{id}/rename") + fun renameSection( + @PathVariable id: String, + @RequestParam title: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.renameSection(token(session), id, title.trim()) + redirect.addFlashAttribute("notice", "Раздел переименован") + "redirect:/admin/content" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/content" + } + + @PostMapping("/sections/{id}/delete") + fun deleteSection( + @PathVariable id: String, + @RequestParam(required = false) moveTo: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + if (moveTo.isNullOrBlank()) { + api.deleteSection(token(session), id) + redirect.addFlashAttribute("notice", "Раздел удалён вместе со статьями") + } else { + api.deleteSectionWithMove(token(session), id, moveTo) + redirect.addFlashAttribute("notice", "Статьи перенесены, раздел удалён") + } + "redirect:/admin/content" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/content" + } + + @PostMapping("/articles/create") + fun createArticle( + @RequestParam(required = false) chapterId: String?, + @RequestParam(required = false) sectionId: String?, + @RequestParam(required = false) newChapterTitle: String?, + @RequestParam(required = false) newSectionTitle: String?, + @RequestParam title: String, + @RequestParam(required = false, defaultValue = "") bodyMd: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.createArticle( + token(session), chapterId, sectionId, + newChapterTitle?.takeIf { it.isNotBlank() }, + newSectionTitle?.takeIf { it.isNotBlank() }, + title.trim(), bodyMd, + ) + redirect.addFlashAttribute("notice", "Статья «${title.trim()}» создана") + "redirect:/admin/content" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/content" + } + + @PostMapping("/articles/{id}/move") + fun moveArticle( + @PathVariable id: String, + @RequestParam sectionId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.moveArticle(token(session), id, sectionId) + redirect.addFlashAttribute("notice", "Статья перемещена") + "redirect:/admin/content" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/content" + } + + private fun token(session: HttpSession): String = + session.getAttribute(SessionKeys.TOKEN) as? String + ?: throw ApiException(401, "Сессия истекла, войдите заново") +} \ No newline at end of file diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt index 4cdc0b8..c9f6082 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt @@ -291,6 +291,217 @@ class ApiClient( .retrieve().body(GradeDto::class.java) } + fun contentTree(token: String?): ContentTreeNodeDto = + call { + val spec = client.get().uri("/api/v1/content/tree") + (if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec) + .retrieve().body(ContentTreeNodeDto::class.java) + } + + fun contentArticle(token: String?, chapter: String, section: String, article: String): ContentArticleDto = + call { + val spec = client.get().uri("/api/v1/content/{c}/{s}/{a}", chapter, section, article) + (if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec) + .retrieve().body(ContentArticleDto::class.java) + } + + fun contentTreeList(token: String?): List { + val type = object : ParameterizedTypeReference>() {} + return call { + val spec = client.get().uri("/api/v1/content/articles") + (if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec) + .retrieve().body(type) + } ?: emptyList() + } + + fun importContent(token: String, filename: String, bytes: ByteArray): ContentImportResultDto { + val parts = LinkedMultiValueMap() + parts.add("file", object : ByteArrayResource(bytes) { + override fun getFilename(): String = filename + }) + return call { + client.post().uri("/api/v1/content/import") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(parts) + .retrieve().body(ContentImportResultDto::class.java) + } + } + + fun updateContentArticle(token: String, articleId: String, title: String, bodyMd: String) { + call { + client.put().uri("/api/v1/content/articles/{id}", articleId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("title" to title, "bodyMd" to bodyMd)) + .retrieve().toBodilessEntity() + } + } + + fun deleteContentArticle(token: String, articleId: String) { + call { + client.delete().uri("/api/v1/content/articles/{id}", articleId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().toBodilessEntity() + } + } + + fun contentAsset(name: String): DownloadDto { + val entity = call { + client.get().uri("/api/v1/content/assets/{name}", name) + .retrieve().toEntity(ByteArray::class.java) + } + return DownloadDto( + bytes = entity.body ?: ByteArray(0), + filename = name, + contentType = entity.headers.contentType?.toString() ?: "application/octet-stream", + ) + } + + fun createLab(token: String, payload: LabCreatePayload): LabDetailDto = + call { + client.post().uri("/api/v1/labs") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve().body(LabDetailDto::class.java) + } + + fun addLevel(token: String, labId: String, payload: LevelSavePayload) { + call { + client.post().uri("/api/v1/labs/{id}/levels", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve().toBodilessEntity() + } + } + + fun renameLevel(token: String, labId: String, levelId: String, title: String) { + call { + client.put().uri("/api/v1/labs/{id}/levels/{lid}", labId, levelId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("title" to title)) + .retrieve().toBodilessEntity() + } + } + + fun deleteLevel(token: String, labId: String, levelId: String) { + call { + client.delete().uri("/api/v1/labs/{id}/levels/{lid}", labId, levelId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().toBodilessEntity() + } + } + + fun createStudent(token: String, groupId: String, payload: CreateStudentRequestDto): CredentialDto = + call { + client.post().uri("/api/v1/groups/{id}/students", groupId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve().body(CredentialDto::class.java) + } + + fun createChapter(token: String, title: String, slug: String?): Map { + val type = object : ParameterizedTypeReference>() {} + return call { + client.post().uri("/api/v1/content/chapters") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("title" to title, "slug" to slug)) + .retrieve().body(type) + } + } + + fun renameChapter(token: String, chapterId: String, title: String) { + call { + client.put().uri("/api/v1/content/chapters/{id}", chapterId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("title" to title)) + .retrieve().toBodilessEntity() + } + } + + fun deleteChapter(token: String, chapterId: String) { + call { + client.delete().uri("/api/v1/content/chapters/{id}", chapterId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().toBodilessEntity() + } + } + + fun createSection(token: String, chapterId: String, title: String, slug: String?): Map { + val type = object : ParameterizedTypeReference>() {} + return call { + client.post().uri("/api/v1/content/sections?chapterId={cid}", chapterId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("title" to title, "slug" to slug)) + .retrieve().body(type) + } + } + + fun renameSection(token: String, sectionId: String, title: String) { + call { + client.put().uri("/api/v1/content/sections/{id}", sectionId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("title" to title)) + .retrieve().toBodilessEntity() + } + } + + fun deleteSection(token: String, sectionId: String) { + call { + client.delete().uri("/api/v1/content/sections/{id}", sectionId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().toBodilessEntity() + } + } + + fun deleteSectionWithMove(token: String, sectionId: String, moveTo: String) { + call { + client.delete() + .uri { b -> b.path("/api/v1/content/sections/{id}").queryParam("moveTo", moveTo).build(sectionId) } + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().toBodilessEntity() + } + } + + fun createArticle( + token: String, chapterId: String?, sectionId: String?, + newChapterTitle: String?, newSectionTitle: String?, + title: String, bodyMd: String, + ): ContentArticleDto = + call { + client.post().uri("/api/v1/content/articles") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body( + mapOf( + "chapterId" to chapterId, + "sectionId" to sectionId, + "newChapterTitle" to newChapterTitle, + "newSectionTitle" to newSectionTitle, + "title" to title, + "bodyMd" to bodyMd, + ), + ) + .retrieve().body(ContentArticleDto::class.java) + } + + fun moveArticle(token: String, articleId: String, newSectionId: String): ContentArticleDto = + call { + client.post().uri("/api/v1/content/articles/{id}/move", articleId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(ArticleMovePayload(newSectionId)) + .retrieve().body(ContentArticleDto::class.java) + } + private companion object { val DETAIL_PATTERN = Regex("\"detail\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"") } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt index 9041bea..bb6bc39 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt @@ -213,6 +213,105 @@ data class GradeSetPayload( val score: String, ) +data class LabCreatePayload( + val number: Int, + val title: String, + val deadlineAt: String?, + val defenseAt: String?, + val pointsMax: java.math.BigDecimal, + val status: String, + val descriptionMd: String, + val levels: List, +) + +data class LevelPayload( + val code: String, + val title: String, + val position: Int, +) + +data class LevelSavePayload( + val code: String, + val title: String, + val description: String = "", +) + +data class LevelRenamePayload( + val title: String, + val description: 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) + +data class ArticleCreatePayload(val sectionId: String, val title: String, val bodyMd: String) + +data class ArticleMovePayload(val sectionId: String) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class CreateStudentRequestDto( + val studentNo: String, + val lastName: String, + val firstName: String = "", + val middleName: String = "", + val basis: String = "", + val note: String = "", +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class ContentTreeNodeDto( + val chapters: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class ContentChapterNodeDto( + val id: String = "", + val title: String = "", + val slug: String = "", + val position: Int? = 0, + val sections: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class ContentSectionNodeDto( + val id: String = "", + val title: String = "", + val slug: String = "", + val position: Int? = 0, + val articles: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class ContentArticleNodeDto( + val id: String = "", + val title: String = "", + val slug: String = "", + val position: Int? = 0, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class ContentArticleDto( + val id: String = "", + val chapterSlug: String = "", + val chapterTitle: String = "", + val sectionSlug: String = "", + val sectionTitle: String = "", + val slug: String = "", + val title: String = "", + val bodyMd: String = "", +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class ContentImportResultDto( + val chapters: Int = 0, + val sections: Int = 0, + val articles: Int = 0, + val assets: Int = 0, +) + class DownloadDto( val bytes: ByteArray, val filename: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt b/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt index f0e50c4..460ff1f 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt @@ -1,5 +1,6 @@ package ru.ysu.lms.web.auth +import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpSession import org.springframework.stereotype.Controller import org.springframework.ui.Model @@ -32,13 +33,13 @@ class AuthPageController(private val api: ApiClient) { @PostMapping("/login") fun login( + request: HttpServletRequest, @RequestParam login: String, @RequestParam password: String, - session: HttpSession, model: Model, ): String = try { val auth = api.login(login.trim(), password) - store(session, auth) + store(request, auth) redirectFor(auth) } catch (ex: ApiException) { model.addAttribute("adminExists", api.adminExists()) @@ -57,14 +58,14 @@ class AuthPageController(private val api: ApiClient) { @PostMapping("/setup") fun setup( + request: HttpServletRequest, @RequestParam login: String, @RequestParam password: String, @RequestParam password2: String, - session: HttpSession, model: Model, ): String = try { val auth = api.setup(login.trim(), password, password2) - store(session, auth) + store(request, auth) "redirect:/admin" } catch (ex: ApiException) { model.addAttribute("error", ex.message) @@ -104,7 +105,10 @@ class AuthPageController(private val api: ApiClient) { private fun currentUser(session: HttpSession): SessionUser? = session.getAttribute(SessionKeys.USER) as? SessionUser - private fun store(session: HttpSession, auth: AuthResponseDto) { + private fun store(request: HttpServletRequest, auth: AuthResponseDto) { + // Защита от session fixation: новая сессия после аутентификации. + request.getSession(false)?.invalidate() + val session = request.getSession(true) session.setAttribute(SessionKeys.TOKEN, auth.token) session.setAttribute( SessionKeys.USER, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/config/CsrfInterceptor.kt b/web/src/main/kotlin/ru/ysu/lms/web/config/CsrfInterceptor.kt new file mode 100644 index 0000000..8d8e504 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/config/CsrfInterceptor.kt @@ -0,0 +1,43 @@ +package ru.ysu.lms.web.config + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.stereotype.Component +import org.springframework.web.servlet.HandlerInterceptor +import java.util.UUID + +/** + * Synchronizer token: на каждый GET кладём в сессию свежий токен, на POST + * проверяем поле _csrf. Защита от CSRF для сессионных cookie web-BFF. + */ +@Component +class CsrfInterceptor : HandlerInterceptor { + + override fun preHandle( + request: HttpServletRequest, + response: HttpServletResponse, + handler: Any, + ): Boolean { + val session = request.getSession(true) + var token = session.getAttribute(ATTR) as? String + if (token.isNullOrBlank()) { + token = UUID.randomUUID().toString() + session.setAttribute(ATTR, token) + } + request.setAttribute(ATTR, token) + + if (request.method.equals("POST", ignoreCase = true)) { + val sent = request.getParameter(FIELD) + if (sent != token) { + response.sendError(HttpServletResponse.SC_FORBIDDEN, "CSRF token mismatch") + return false + } + } + return true + } + + companion object { + const val ATTR = "csrfToken" + const val FIELD = "_csrf" + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt b/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt index 8d946e7..fec260b 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt @@ -15,9 +15,11 @@ import ru.ysu.lms.web.session.SessionUser class WebConfig( private val authInterceptor: AuthInterceptor, private val adminInterceptor: AdminInterceptor, + private val csrfInterceptor: CsrfInterceptor, ) : WebMvcConfigurer { override fun addInterceptors(registry: InterceptorRegistry) { + registry.addInterceptor(csrfInterceptor).addPathPatterns("/**") registry.addInterceptor(authInterceptor) .addPathPatterns("/admin/**", "/student/**", "/change-password") registry.addInterceptor(adminInterceptor) diff --git a/web/src/main/kotlin/ru/ysu/lms/web/learn/ContentAssetProxy.kt b/web/src/main/kotlin/ru/ysu/lms/web/learn/ContentAssetProxy.kt new file mode 100644 index 0000000..5c2ef13 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/learn/ContentAssetProxy.kt @@ -0,0 +1,26 @@ +package ru.ysu.lms.web.learn + +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.RestController +import ru.ysu.lms.web.api.ApiClient + +/** + * Проксирует картинки статей (public path) через BFF: страницы /learn/... + * отдаются web, а src картинок указывает на этот же хост. + */ +@RestController +class ContentAssetProxy(private val api: ApiClient) { + + @GetMapping("/api/v1/content/assets/{name}") + fun asset(@PathVariable name: String): ResponseEntity { + val asset = api.contentAsset(name) + val media = runCatching { MediaType.parseMediaType(asset.contentType) } + .getOrElse { MediaType.APPLICATION_OCTET_STREAM } + return ResponseEntity.ok() + .contentType(media) + .body(asset.bytes) + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt b/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt new file mode 100644 index 0000000..29677cb --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt @@ -0,0 +1,79 @@ +package ru.ysu.lms.web.learn + +import jakarta.servlet.http.HttpSession +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import ru.ysu.lms.markdown.MarkdownRenderer +import ru.ysu.lms.web.api.ApiClient +import ru.ysu.lms.web.api.ContentArticleDto +import ru.ysu.lms.web.api.ContentTreeNodeDto +import ru.ysu.lms.web.session.SessionKeys + +@Controller +class LearnController( + private val api: ApiClient, + private val markdown: MarkdownRenderer, +) { + + private fun token(session: HttpSession): String? = + session.getAttribute(SessionKeys.TOKEN) as? String + + @GetMapping("/learn") + fun index(session: HttpSession, model: Model): String { + model.addAttribute("tree", api.contentTree(token(session))) + return "learn/index" + } + + @GetMapping("/learn/{chapter}/{section}") + fun section( + @PathVariable chapter: String, + @PathVariable section: String, + session: HttpSession, + model: Model, + ): String { + val tree: ContentTreeNodeDto = api.contentTree(token(session)) + val ch = tree.chapters.firstOrNull { it.slug == chapter } ?: return "redirect:/learn" + val sec = ch.sections.firstOrNull { it.slug == section } ?: return "redirect:/learn" + model.addAttribute("tree", tree) + model.addAttribute("chapter", ch) + model.addAttribute("section", sec) + return "learn/section" + } + + @GetMapping("/learn/{chapter}/{section}/{article}") + fun article( + @PathVariable chapter: String, + @PathVariable section: String, + @PathVariable article: String, + session: HttpSession, + model: Model, + ): String { + val a: ContentArticleDto = api.contentArticle(token(session), chapter, section, article) + model.addAttribute("article", a.toView(markdown.render(a.bodyMd))) + return "learn/article" + } +} + +data class LearnArticleView( + val id: String, + val chapterSlug: String, + val chapterTitle: String, + val sectionSlug: String, + val sectionTitle: String, + val slug: String, + val title: String, + val bodyHtml: String, +) + +fun ContentArticleDto.toView(bodyHtml: String): LearnArticleView = LearnArticleView( + id = id, + chapterSlug = chapterSlug, + chapterTitle = chapterTitle, + sectionSlug = sectionSlug, + sectionTitle = sectionTitle, + slug = slug, + title = title, + bodyHtml = bodyHtml, +) diff --git a/web/src/main/resources/application.yml b/web/src/main/resources/application.yml index c3a344d..3343281 100644 --- a/web/src/main/resources/application.yml +++ b/web/src/main/resources/application.yml @@ -1,5 +1,10 @@ server: port: ${SERVER_PORT:8090} + servlet: + session: + cookie: + http-only: true + same-site: lax spring: application: diff --git a/web/src/main/resources/static/css/app.css b/web/src/main/resources/static/css/app.css index 6562372..6d82942 100644 --- a/web/src/main/resources/static/css/app.css +++ b/web/src/main/resources/static/css/app.css @@ -167,6 +167,27 @@ tr:last-child td { border-bottom: none; } .filter-select { width: auto; } .hidden { display: none !important; } +/* Toast-уведомление */ +.toast { position: fixed; top: 1rem; right: 1rem; z-index: 50; width: 20rem; } + +/* Modals */ +dialog.modal { + border: none; border-radius: 0.75rem; padding: 0; + background: var(--c-surface); color: var(--c-text); + width: min(34rem, calc(100vw - 2rem)); + box-shadow: 0 20px 60px rgba(0,0,0,0.35); +} +dialog.modal::backdrop { background: rgba(0,0,0,0.55); } +.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; border-bottom: 1px solid var(--c-border); } +.modal-title { margin: 0; font-size: 1.0625rem; font-weight: 600; } +.modal-close { border: none; background: none; cursor: pointer; font-size: 1.25rem; color: var(--c-text-muted); padding: 0 0.25rem; } +.modal-close:hover { color: var(--c-text); } +.modal-body { padding: 1.5rem; max-height: 70vh; overflow-y: auto; } + +/* Двухуровневые формы уровней лабы */ +.level-row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; } +.level-row .input { flex: 1; } + /* Markdown body */ .md-body { font-size: 0.9375rem; } .md-body h1, .md-body h2, .md-body h3 { margin: 1rem 0 0.5rem; line-height: 1.3; } diff --git a/web/src/main/resources/static/favicon.svg b/web/src/main/resources/static/favicon.svg new file mode 100644 index 0000000..9515f38 --- /dev/null +++ b/web/src/main/resources/static/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/src/main/resources/templates/admin/dashboard.html b/web/src/main/resources/templates/admin/dashboard.html index ce1829b..8d77b9f 100644 --- a/web/src/main/resources/templates/admin/dashboard.html +++ b/web/src/main/resources/templates/admin/dashboard.html @@ -4,24 +4,51 @@

Панель преподавателя

+

Группы

0

Студенты

0

-

Лабораторные

скоро

+

Лабораторные

0

+

опубликовано: 0

+

Средний балл

+

—

+

—

-
-
-

Группы

- Все группы → + +
+
+
+

Лабораторные

+ Все → +
+
+ + + + + + + + +
——
Лабораторных нет.
+
-
- - - - - - -
НазваниеСтудентов
—0
Групп пока нет.
+
+
+

Группы

+ Все → +
+
+ + + + + + + + +
——
Групп нет.
+
diff --git a/web/src/main/resources/templates/admin/group.html b/web/src/main/resources/templates/admin/group.html index 23090d4..6527211 100644 --- a/web/src/main/resources/templates/admin/group.html +++ b/web/src/main/resources/templates/admin/group.html @@ -10,6 +10,7 @@

Студентов: 0

+ Выгрузить XLSX CSV
@@ -67,6 +68,7 @@ + @@ -95,7 +97,8 @@ изменён
- + +
@@ -104,5 +107,42 @@
+ +
+
+

Студент добавлен

+

—

+

Логин: —

+

Пароль: —

+
+
+ + + + + \ No newline at end of file diff --git a/web/src/main/resources/templates/admin/groups.html b/web/src/main/resources/templates/admin/groups.html index 26fca4b..2bb1ca5 100644 --- a/web/src/main/resources/templates/admin/groups.html +++ b/web/src/main/resources/templates/admin/groups.html @@ -11,6 +11,7 @@

Создать группу

+

Импорт студентов

@@ -18,6 +19,7 @@
+
diff --git a/web/src/main/resources/templates/admin/lab-edit.html b/web/src/main/resources/templates/admin/lab-edit.html index 96e28d4..f2d16ed 100644 --- a/web/src/main/resources/templates/admin/lab-edit.html +++ b/web/src/main/resources/templates/admin/lab-edit.html @@ -34,6 +34,7 @@
+

Варианты

@@ -52,10 +53,12 @@
+
+
@@ -75,6 +78,7 @@
+ diff --git a/web/src/main/resources/templates/admin/lab.html b/web/src/main/resources/templates/admin/lab.html index fe984c1..d2d009e 100644 --- a/web/src/main/resources/templates/admin/lab.html +++ b/web/src/main/resources/templates/admin/lab.html @@ -6,39 +6,83 @@ ← Все лабораторные
-

ЛР 1

-

Статус: —

+

ЛР 1

+

+ Статус: — + · Срок: + · Защита: + · Максимум: б. +

- Редактировать +
+
+
-

Уровни вариантов

-
- - - - - - - - - - -
КодНазваниеВариантов
——0
Уровни не найдены.
-
+

Условие

+
-

Студенты и варианты

+

Уровни и варианты

+ +
+ +
+
+
+

+ basic — + Уровень + (0 вар.) +

+
+ +
+ + +
+ +
+
+ + + + + + + + + + + +
№НазваниеУсловие
——— + +
+ + +
+
Вариантов пока нет.
+
+
+ +

Студенты и варианты

+
+

Уровень по умолчанию — самый простой. Селекты: выдать вариант посложнее или назначить вручную.

-

Уровень по умолчанию — самый простой. Селекты позволяют выдать вариант посложнее или назначить вариант вручную.

-

Группа

@@ -65,6 +107,7 @@ th:selected="${level.id == s.levelId}">— + @@ -78,6 +121,7 @@ + — @@ -88,13 +132,126 @@
- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/main/resources/templates/admin/labs.html b/web/src/main/resources/templates/admin/labs.html index 734df06..f9e6cfe 100644 --- a/web/src/main/resources/templates/admin/labs.html +++ b/web/src/main/resources/templates/admin/labs.html @@ -3,18 +3,14 @@ Лабораторные — LMS
-

Лабораторные

+
+

Лабораторные

+ +
-
-
-

Импорт лабораторной

-

Принимаются .zip (lab.yaml + README.md + variants/*.md) или .json.

-
- -
-
-
+ +
@@ -23,12 +19,49 @@ - +
№НазваниеВариантовСтатус
— 0 —
Лабораторных пока нет.
+ + + + + \ No newline at end of file diff --git a/web/src/main/resources/templates/change_password.html b/web/src/main/resources/templates/change_password.html index 55439cd..8ae516e 100644 --- a/web/src/main/resources/templates/change_password.html +++ b/web/src/main/resources/templates/change_password.html @@ -12,6 +12,7 @@

Минимум 8 символов.

+
diff --git a/web/src/main/resources/templates/fragments/base.html b/web/src/main/resources/templates/fragments/base.html index a6b726b..2ff8558 100644 --- a/web/src/main/resources/templates/fragments/base.html +++ b/web/src/main/resources/templates/fragments/base.html @@ -4,11 +4,14 @@ LMS + @@ -16,13 +19,17 @@