UI rework: modal dialogs, inline lab editing, manual creation, content management

- Dashboard: real stats (groups, students, labs, average grade), labs/groups
  summary lists, no placeholder tile
- Lab page: view + edit combined — metadata form in modal, condition rendered
  as markdown, per-level variant tables with add/edit/delete dialogs,
  level rename/delete, student level/variant selectors with group filter
- Labs list: create-lab dialog with manual level entry (code; title lines)
- Groups: create-group dialog, add-student dialog (auto password, toast card)
- Content: public /learn reader is the single place; admin buttons (new
  article with chapter/section create-or-select, rename/delete chapters and
  sections, move articles between sections, import zip) appear for admins;
  removed separate 'Контент' admin section
- Buttons use btn classes instead of nav-link
- Favicon: generated SVG book icon
- Security: CSRF synchronizer token interceptor (replaced spring-security in
  web which broke logback under nested jars), session regenerated on login,
  hardened session cookie, gateway actuator limited to health,info
- Jackson 3: nullable-safe request DTOs for labs (FAIL_ON_NULL_FOR_PRIMITIVES)
- academic: POST /labs (manual create with levels), level add/rename/delete,
  content chapters/sections CRUD, article create/move/delete endpoints
This commit is contained in:
2026-09-11 16:00:56 +03:00
parent cca20b5416
commit 08890875e5
45 changed files with 2435 additions and 86 deletions

1
.idea/vcs.xml generated
View File

@@ -2,6 +2,5 @@
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/oldLMS" vcs="Git" />
</component> </component>
</project> </project>

View File

@@ -0,0 +1,59 @@
package ru.ysu.lms.academic.content
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import ru.ysu.lms.common.BadRequestException
import java.nio.file.Files
import java.nio.file.Path
import java.util.UUID
@Component
class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") root: String) {
private val rootDir: Path = Path.of(root)
init {
Files.createDirectories(rootDir)
}
data class StoredAsset(val publicName: String, val storedPath: Path, val mime: String)
fun store(originalName: String, bytes: ByteArray): StoredAsset {
if (bytes.size > MAX_ASSET_BYTES) {
throw BadRequestException("Файл $originalName слишком большой (> ${MAX_ASSET_BYTES / 1024 / 1024} МБ)")
}
val ext = originalName.substringAfterLast('.', "").lowercase()
val allowed = setOf("png", "jpg", "jpeg", "gif", "svg", "webp")
if (ext !in allowed) {
throw BadRequestException("Недопустимый тип файла: $originalName (разрешены ${allowed.joinToString(", ")})")
}
val publicName = "${UUID.randomUUID()}.$ext"
val target = rootDir.resolve(publicName).normalize()
if (!target.startsWith(rootDir)) {
throw BadRequestException("Некорректный путь файла")
}
Files.write(target, bytes)
return StoredAsset(publicName, target, mimeFor(ext))
}
fun read(publicName: String): Pair<Path, String>? {
val clean = publicName.substringAfterLast('/')
val target = rootDir.resolve(clean).normalize()
if (!target.startsWith(rootDir) || target.parent != rootDir) return null
if (!Files.isRegularFile(target)) return null
val ext = clean.substringAfterLast('.', "").lowercase()
return target to mimeFor(ext)
}
private fun mimeFor(ext: String): String = when (ext) {
"png" -> "image/png"
"jpg", "jpeg" -> "image/jpeg"
"gif" -> "image/gif"
"svg" -> "image/svg+xml"
"webp" -> "image/webp"
else -> "application/octet-stream"
}
private companion object {
const val MAX_ASSET_BYTES = 10 * 1024 * 1024
}
}

View File

@@ -0,0 +1,477 @@
package ru.ysu.lms.academic.content
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.ContentArticle
import ru.ysu.lms.academic.domain.ContentChapter
import ru.ysu.lms.academic.domain.ContentSection
import ru.ysu.lms.academic.repo.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.NotFoundException
import java.io.ByteArrayInputStream
import java.util.UUID
import java.util.zip.ZipInputStream
data class ArticleTreeNode(
val id: String,
val title: String,
val slug: String,
val position: Int,
)
data class SectionTreeNode(
val id: String,
val title: String,
val slug: String,
val position: Int,
val articles: List<ArticleTreeNode>,
)
data class ChapterTreeNode(
val id: String,
val title: String,
val slug: String,
val position: Int,
val sections: List<SectionTreeNode>,
)
data class ContentTree(val chapters: List<ChapterTreeNode>)
data class ContentArticleView(
val id: String,
val chapterSlug: String,
val chapterTitle: String,
val sectionSlug: String,
val sectionTitle: String,
val slug: String,
val title: String,
val bodyMd: String,
)
data class ContentImportResult(
val chapters: Int,
val sections: Int,
val articles: Int,
val assets: Int,
)
private data class ParsedArticle(
val path: String,
val chapterPos: Int,
val chapterDir: String,
val sectionPos: Int,
val sectionDir: String,
val articlePos: Int,
val fileTitle: String,
val fmSlug: String?,
val body: String,
)
@Service
class ContentService(
private val chapters: ContentChapterRepository,
private val sections: ContentSectionRepository,
private val articles: ContentArticleRepository,
private val assets: AssetStorage,
) {
@Transactional(readOnly = true)
fun tree(): ContentTree {
val chapterList = chapters.findAllByOrderByPositionAscIdAsc()
val sectionList = sections.findAllByOrderByPositionAscIdAsc().groupBy { it.chapterId }
val articleList = articles.findAllByOrderByPositionAscIdAsc().groupBy { it.sectionId }
return ContentTree(
chapterList.map { ch ->
ChapterTreeNode(
id = ch.id.toString(),
title = ch.title,
slug = ch.slug,
position = ch.position,
sections = (sectionList[ch.id] ?: emptyList()).map { sec ->
SectionTreeNode(
id = sec.id.toString(),
title = sec.title,
slug = sec.slug,
position = sec.position,
articles = (articleList[sec.id] ?: emptyList()).map { a ->
ArticleTreeNode(a.id.toString(), a.title, a.slug, a.position)
},
)
},
)
},
)
}
@Transactional(readOnly = true)
fun findArticle(chapterSlug: String, sectionSlug: String, articleSlug: String): ContentArticleView {
val ch = chapters.findBySlug(chapterSlug) ?: throw NotFoundException("Глава не найдена")
val sec = sections.findByChapterIdAndSlug(ch.id, sectionSlug) ?: throw NotFoundException("Раздел не найден")
val a = articles.findBySectionIdAndSlug(sec.id, articleSlug) ?: throw NotFoundException("Статья не найдена")
return ContentArticleView(
id = a.id.toString(),
chapterSlug = ch.slug,
chapterTitle = ch.title,
sectionSlug = sec.slug,
sectionTitle = sec.title,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
)
}
@Transactional(readOnly = true)
fun listAll(): List<ContentArticleView> {
val chById = chapters.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
val secById = sections.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
return articles.findAllByOrderByPositionAscIdAsc().mapNotNull { a ->
val sec = secById[a.sectionId] ?: return@mapNotNull null
val ch = chById[sec.chapterId] ?: return@mapNotNull null
ContentArticleView(
id = a.id.toString(),
chapterSlug = ch.slug,
chapterTitle = ch.title,
sectionSlug = sec.slug,
sectionTitle = sec.title,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
)
}
}
@Transactional
fun updateArticle(articleId: UUID, title: String, bodyMd: String) {
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
a.title = title.trim()
a.bodyMd = bodyMd
articles.save(a)
}
@Transactional
fun deleteArticle(articleId: UUID) {
if (!articles.existsById(articleId)) throw NotFoundException("Статья не найдена")
articles.deleteById(articleId)
}
@Transactional
fun createChapter(title: String, slug: String?): ChapterTreeNode {
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
val cleanSlug = slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
?: uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1))
return ChapterTreeNode(ch.id.toString(), ch.title, ch.slug, ch.position, emptyList())
}
@Transactional
fun renameChapter(chapterId: UUID, title: String) {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
ch.title = title.trim()
chapters.save(ch)
}
@Transactional
fun deleteChapter(chapterId: UUID) {
if (!chapters.existsById(chapterId)) throw NotFoundException("Глава не найдена")
chapters.deleteById(chapterId)
}
@Transactional
fun createSection(chapterId: UUID, title: String, slug: String?): SectionTreeNode {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым")
val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id)
val cleanSlug = slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
?: uniqueSlug(title, siblings.map { it.slug }.toSet())
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
val sec = sections.save(
ContentSection(chapterId = ch.id, title = title.trim(), slug = cleanSlug, position = maxPos + 1),
)
return SectionTreeNode(sec.id.toString(), sec.title, sec.slug, sec.position, emptyList())
}
@Transactional
fun renameSection(sectionId: UUID, title: String) {
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
sec.title = title.trim()
sections.save(sec)
}
@Transactional
fun deleteSection(sectionId: UUID) {
if (!sections.existsById(sectionId)) throw NotFoundException("Раздел не найден")
sections.deleteById(sectionId)
}
/** Переносит все статьи раздела в другой и удаляет раздел. */
@Transactional
fun moveArticlesAndDeleteSection(sectionId: UUID, targetSectionId: UUID) {
val src = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
val dst = sections.findById(targetSectionId).orElseThrow { NotFoundException("Раздел назначения не найден") }
if (src.id == dst.id) throw BadRequestException("Раздел назначения совпадает с исходным")
val moving = articles.findBySectionIdOrderByPositionAscIdAsc(src.id)
var pos = articles.findBySectionIdOrderByPositionAscIdAsc(dst.id).maxOfOrNull { it.position } ?: 0
for (a in moving) {
pos += 1
a.sectionId = dst.id
a.position = pos
articles.save(a)
}
sections.delete(src)
}
/** Создаёт статью; если раздела нет — создаёт главу/раздел по названиям. */
@Transactional
fun createArticlePlaced(title: String, bodyMd: String, chapterId: UUID?, newChapterTitle: String?,
sectionId: UUID?, newSectionTitle: String?): ContentArticleView {
val targetSection: ContentSection = when {
sectionId != null -> sections.findById(sectionId)
.orElseThrow { NotFoundException("Раздел не найден") }
!newSectionTitle.isNullOrBlank() -> {
val chapter: ContentChapter = when {
chapterId != null -> chapters.findById(chapterId)
.orElseThrow { NotFoundException("Глава не найдена") }
!newChapterTitle.isNullOrBlank() -> {
val node = createChapter(newChapterTitle, null)
chapters.findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Глава не найдена") }
}
else -> throw BadRequestException("Укажите главу")
}
val node = createSection(chapter.id, newSectionTitle, null)
sections.findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Раздел не найден") }
}
else -> throw BadRequestException("Укажите раздел")
}
return createArticle(targetSection.id, title, bodyMd)
}
@Transactional
fun createArticle(sectionId: UUID, title: String, bodyMd: String): ContentArticleView {
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
val slug = uniqueSlug(title, siblings.map { it.slug }.toSet())
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
val a = articles.save(
ContentArticle(sectionId = sec.id, title = title.trim(), slug = slug, bodyMd = bodyMd, position = maxPos + 1),
)
return ContentArticleView(
id = a.id.toString(), chapterSlug = ch.slug, chapterTitle = ch.title,
sectionSlug = sec.slug, sectionTitle = sec.title,
slug = a.slug, title = a.title, bodyMd = a.bodyMd,
)
}
@Transactional
fun moveArticle(articleId: UUID, newSectionId: UUID): ContentArticleView {
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
val sec = sections.findById(newSectionId).orElseThrow { NotFoundException("Раздел не найден") }
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
a.sectionId = sec.id
a.position = (siblings.maxOfOrNull { it.position } ?: 0) + 1
articles.save(a)
return ContentArticleView(
id = a.id.toString(), chapterSlug = ch.slug, chapterTitle = ch.title,
sectionSlug = sec.slug, sectionTitle = sec.title,
slug = a.slug, title = a.title, bodyMd = a.bodyMd,
)
}
private fun uniqueSlug(title: String, taken: Set<String>): String {
val base = slugify(title) ?: "item"
if (base !in taken) return base
var i = 2
while ("$base-$i" in taken) i += 1
return "$base-$i"
}
/**
* Импорт zip со структурой каталогов:
* chapters/01-глава/01-раздел/01-статья.md (front-matter: title, slug),
* картинки в assets/.
*/
@Transactional
fun importZip(filename: String, bytes: ByteArray): ContentImportResult {
if (!filename.lowercase().endsWith(".zip")) {
throw BadRequestException("Ожидается .zip архив")
}
val files = readZip(bytes)
if (files.isEmpty()) throw BadRequestException("Архив пуст")
val assetFiles = files.filterKeys { it.contains("/assets/") || it.startsWith("assets/") }
val contentFiles = files.filterKeys { k ->
(k.contains("/chapters/") || k.startsWith("chapters/")) && k.endsWith(".md")
}
if (contentFiles.isEmpty()) throw BadRequestException("В архиве нет каталога chapters/ с .md файлами")
val linkRewrite = mutableMapOf<String, String>()
for ((path, data) in assetFiles) {
val name = path.substringAfterLast('/')
if (name.isBlank()) continue
val stored = assets.store(name, data)
linkRewrite["assets/$name"] = "/api/v1/content/assets/${stored.publicName}"
}
val parsed = contentFiles.map { (path, data) ->
val clean = path.removePrefix("/").removePrefix("chapters/").removeSuffix(".md")
val parts = clean.split('/')
if (parts.size < 3) {
throw BadRequestException("Неверная структура: $path (ожидается chapters/NN-глава/NN-раздел/NN-статья.md)")
}
val chapterDir = parts[0]
val sectionDir = parts[1]
val fileName = parts.drop(2).joinToString("-")
val parsedContent = splitFrontMatter(String(data, Charsets.UTF_8))
ParsedArticle(
path = path,
chapterPos = positionOf(chapterDir),
chapterDir = titleFromDir(chapterDir),
sectionPos = positionOf(sectionDir),
sectionDir = titleFromDir(sectionDir),
articlePos = positionOf(fileName),
fileTitle = parsedContent.first["title"] ?: titleFromDir(fileName),
fmSlug = parsedContent.first["slug"],
body = parsedContent.second,
)
}.sortedWith(
compareBy(
{ it.chapterPos },
{ it.chapterDir },
{ it.sectionPos },
{ it.sectionDir },
{ it.articlePos },
),
)
var chCount = 0
var secCount = 0
var artCount = 0
var currentChapter: ContentChapter? = null
var currentSection: ContentSection? = null
for (p in parsed) {
if (currentChapter?.title != p.chapterDir) {
val slug = chapterSlug(p) ?: "chapter-${chCount + 1}"
currentChapter = chapters.findBySlug(slug) ?: chapters.save(
ContentChapter(title = p.chapterDir, slug = slug, position = p.chapterPos),
)
chCount += 1
currentSection = null
}
val chapter = currentChapter ?: throw BadRequestException("Не удалось определить главу")
if (currentSection?.title != p.sectionDir) {
val slug = sectionSlug(p) ?: "section-${secCount + 1}"
currentSection = sections.findByChapterIdAndSlug(chapter.id, slug) ?: sections.save(
ContentSection(chapterId = chapter.id, title = p.sectionDir, slug = slug, position = p.sectionPos),
)
secCount += 1
}
val section = currentSection ?: throw BadRequestException("Не удалось определить раздел")
var body = p.body
for ((from, to) in linkRewrite) {
body = body.replace(from, to)
}
val slug = p.fmSlug?.takeIf { it.isNotBlank() }
?: articleSlug(p)
?: "article-${artCount + 1}"
val existing = articles.findBySectionIdAndSlug(section.id, slug)
val article = (existing ?: ContentArticle(sectionId = section.id, slug = slug)).apply {
title = p.fileTitle
bodyMd = body
position = p.articlePos
}
articles.save(article)
artCount += 1
}
return ContentImportResult(chapters = chCount, sections = secCount, articles = artCount, assets = linkRewrite.size)
}
private fun chapterSlug(p: ParsedArticle): String? =
slugify("%02d".format(p.chapterPos) + "-" + p.chapterDir)
private fun sectionSlug(p: ParsedArticle): String? =
slugify("%02d".format(p.sectionPos) + "-" + p.sectionDir)
private fun articleSlug(p: ParsedArticle): String? =
slugify("%02d".format(p.articlePos) + "-" + p.fileTitle)
private fun readZip(bytes: ByteArray): Map<String, ByteArray> {
val result = mutableMapOf<String, ByteArray>()
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (!entry.isDirectory) {
result[entry.name.removePrefix("/")] = zis.readAllBytes()
}
entry = zis.nextEntry
}
}
return result
}
private fun splitFrontMatter(content: String): Pair<Map<String, String>, String> {
if (!content.startsWith("---")) return emptyMap<String, String>() to content
val lines = content.lines()
val end = lines.withIndex()
.firstOrNull { it.index != 0 && it.value.trim() == "---" }
?.index ?: return emptyMap<String, String>() to content
val fm = mutableMapOf<String, String>()
for (line in lines.subList(1, end)) {
val parts = line.split(":", limit = 2)
if (parts.size == 2) {
val v = parts[1].trim()
val unquoted = if (v.length >= 2 && v.first() == '"' && v.last() == '"') {
v.substring(1, v.length - 1)
} else {
v
}
fm[parts[0].trim()] = unquoted
}
}
return fm to lines.drop(end + 1).joinToString("\n").trim()
}
private fun positionOf(name: String): Int {
val m = Regex("^(\\d{1,3})[-_]").find(name.substringAfterLast('/'))
return m?.groupValues?.get(1)?.toIntOrNull() ?: 999
}
private fun titleFromDir(dir: String): String {
val raw = dir.substringAfterLast('/')
.replace(Regex("^\\d{1,3}[-_]"), "")
.replace('_', ' ')
return raw.ifBlank { dir }
}
private fun slugify(value: String): String? {
val translit = TRANSLIT.entries.fold(value.lowercase()) { acc, (from, to) -> acc.replace(from, to) }
val slug = translit
.replace(Regex("\\s+"), "-")
.replace(Regex("[^a-z0-9-]"), "")
.replace(Regex("-+"), "-")
.trim('-')
return slug.ifBlank { null }
}
private companion object {
val TRANSLIT = mapOf(
"а" to "a", "б" to "b", "в" to "v", "г" to "g", "д" to "d", "е" to "e", "ё" to "e",
"ж" to "zh", "з" to "z", "и" to "i", "й" to "y", "к" to "k", "л" to "l", "м" to "m",
"н" to "n", "о" to "o", "п" to "p", "р" to "r", "с" to "s", "т" to "t", "у" to "u",
"ф" to "f", "х" to "h", "ц" to "c", "ч" to "ch", "ш" to "sh", "щ" to "sch", "ъ" to "",
"ы" to "y", "ь" to "", "э" to "e", "ю" to "yu", "я" to "ya",
)
}
}

View File

@@ -0,0 +1,106 @@
package ru.ysu.lms.academic.controller
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.academic.content.ContentImportResult
import ru.ysu.lms.academic.content.ContentService
import ru.ysu.lms.common.BadRequestException
import java.util.UUID
@RestController
@RequestMapping("/api/v1/content")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
class ContentAdminController(private val contentService: ContentService) {
data class ArticleUpdatePayload(val title: String, val bodyMd: String)
data class ArticleCreatePayload(
val chapterId: String? = null,
val sectionId: String? = null,
val newChapterTitle: String? = null,
val newSectionTitle: String? = null,
val title: String,
val bodyMd: String = "",
)
data class ArticleMovePayload(val sectionId: String)
data class TitlePayload(val title: String)
data class ChapterCreatePayload(val title: String, val slug: String? = null)
data class SectionCreatePayload(val title: String, val slug: String? = null)
@PostMapping("/import")
fun import(@RequestParam("file") file: MultipartFile): ContentImportResult {
if (file.isEmpty) throw BadRequestException("Выберите файл")
return contentService.importZip(file.originalFilename ?: "content.zip", file.bytes)
}
@GetMapping("/articles")
fun list() = contentService.listAll()
@PostMapping("/chapters")
fun createChapter(@RequestBody body: ChapterCreatePayload) = contentService.createChapter(body.title, body.slug)
@PutMapping("/chapters/{id}")
fun renameChapter(@PathVariable id: UUID, @RequestBody body: TitlePayload) =
contentService.renameChapter(id, body.title)
@DeleteMapping("/chapters/{id}")
fun deleteChapter(@PathVariable id: UUID): ResponseEntity<Void> {
contentService.deleteChapter(id)
return ResponseEntity.noContent().build()
}
@PostMapping("/sections")
fun createSection(@RequestParam chapterId: UUID, @RequestBody body: SectionCreatePayload) =
contentService.createSection(chapterId, body.title, body.slug)
@PutMapping("/sections/{id}")
fun renameSection(@PathVariable id: UUID, @RequestBody body: TitlePayload) =
contentService.renameSection(id, body.title)
@DeleteMapping("/sections/{id}")
fun deleteSection(
@PathVariable id: UUID,
@RequestParam(required = false) moveTo: UUID?,
): ResponseEntity<Void> {
if (moveTo != null) {
contentService.moveArticlesAndDeleteSection(id, moveTo)
} else {
contentService.deleteSection(id)
}
return ResponseEntity.noContent().build()
}
@PostMapping("/articles")
fun createArticle(@RequestBody body: ArticleCreatePayload) = contentService.createArticlePlaced(
title = body.title,
bodyMd = body.bodyMd,
chapterId = body.chapterId?.let { UUID.fromString(it) },
newChapterTitle = body.newChapterTitle,
sectionId = body.sectionId?.let { UUID.fromString(it) },
newSectionTitle = body.newSectionTitle,
)
@PutMapping("/articles/{id}")
fun update(@PathVariable id: UUID, @RequestBody body: ArticleUpdatePayload) {
contentService.updateArticle(id, body.title, body.bodyMd)
}
@PostMapping("/articles/{id}/move")
fun move(@PathVariable id: UUID, @RequestBody body: ArticleMovePayload) =
contentService.moveArticle(id, UUID.fromString(body.sectionId))
@DeleteMapping("/articles/{id}")
fun delete(@PathVariable id: UUID): ResponseEntity<Void> {
contentService.deleteArticle(id)
return ResponseEntity.noContent().build()
}
}

View File

@@ -0,0 +1,60 @@
package ru.ysu.lms.academic.controller
import org.springframework.core.io.FileSystemResource
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.academic.content.AssetStorage
import ru.ysu.lms.academic.content.ContentService
import java.nio.file.Files
/** Публичное чтение учебных материалов — доступно без аутентификации. */
@RestController
@RequestMapping("/api/v1/content")
class ContentPublicController(
private val contentService: ContentService,
private val assetStorage: AssetStorage,
) {
@GetMapping("/tree")
fun tree() = contentService.tree()
@GetMapping("/{chapter}/{section}/{article}")
fun article(
@PathVariable chapter: String,
@PathVariable section: String,
@PathVariable article: String,
): ContentArticleViewDto {
val a = contentService.findArticle(chapter, section, article)
return ContentArticleViewDto(
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle,
sectionSlug = a.sectionSlug, sectionTitle = a.sectionTitle,
slug = a.slug, title = a.title, bodyMd = a.bodyMd,
)
}
data class ContentArticleViewDto(
val id: String,
val chapterSlug: String,
val chapterTitle: String,
val sectionSlug: String,
val sectionTitle: String,
val slug: String,
val title: String,
val bodyMd: String,
)
@GetMapping("/assets/{name}")
fun asset(@PathVariable name: String): ResponseEntity<FileSystemResource> {
val (path, mime) = assetStorage.read(name)
?: return ResponseEntity.notFound().build()
val media = runCatching { MediaType.parseMediaType(mime) }.getOrElse { MediaType.APPLICATION_OCTET_STREAM }
return ResponseEntity.ok()
.contentType(media)
.cacheControl(org.springframework.http.CacheControl.maxAge(java.time.Duration.ofDays(7)))
.body(FileSystemResource(path))
}
}

View File

@@ -54,4 +54,28 @@ class LabController(private val labService: LabService) {
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun deleteVariant(@PathVariable id: UUID, @PathVariable variantId: UUID): LabDetail = fun deleteVariant(@PathVariable id: UUID, @PathVariable variantId: UUID): LabDetail =
labService.deleteVariant(id, variantId) 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)
} }

View File

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

View File

@@ -16,7 +16,7 @@ data class LabLevelMeta(
val code: String, val code: String,
val title: String, val title: String,
val description: String = "", val description: String = "",
val position: Int = 0, val position: Int? = null,
val pointsMax: BigDecimal? = null, val pointsMax: BigDecimal? = null,
) )
@@ -84,14 +84,39 @@ data class LabUpdateRequest(
val title: String, val title: String,
val deadlineAt: String? = null, val deadlineAt: String? = null,
val defenseAt: String? = null, val defenseAt: String? = null,
val pointsMax: BigDecimal, val pointsMax: BigDecimal? = null,
val status: String, val status: String? = null,
val descriptionMd: String, val descriptionMd: String? = null,
) )
data class VariantSaveRequest( data class VariantSaveRequest(
val labLevelId: String, val labLevelId: String,
val number: String, val number: String,
val title: String, val title: String? = null,
val statementMd: String, val statementMd: String? = null,
)
data class LevelSaveRequest(
val code: String,
val title: String? = null,
val description: String? = null,
val position: Int? = null,
)
data class LabCreateRequest(
val number: Int? = null,
val title: String? = null,
val semesterTitle: String? = null,
val semesterYear: Int? = null,
val deadlineAt: String? = null,
val defenseAt: String? = null,
val pointsMax: BigDecimal? = null,
val status: String? = null,
val descriptionMd: String? = null,
val levels: List<LevelSaveRequest> = emptyList(),
)
data class LevelRenameRequest(
val title: String,
val description: String? = null,
) )

View File

@@ -4,6 +4,7 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.Lab import ru.ysu.lms.academic.domain.Lab
import ru.ysu.lms.academic.domain.LabLevel 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.domain.Variant
import ru.ysu.lms.academic.repo.LabLevelRepository import ru.ysu.lms.academic.repo.LabLevelRepository
import ru.ysu.lms.academic.repo.LabRepository 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.academic.repo.VariantRepository
import ru.ysu.lms.common.NotFoundException import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException
import java.math.BigDecimal import java.math.BigDecimal
import java.time.Instant import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -53,8 +55,8 @@ class LabService(
lab = lab, lab = lab,
code = meta.code, code = meta.code,
title = meta.title, title = meta.title,
description = meta.description, description = meta.description.orEmpty(),
position = meta.position, position = meta.position ?: 0,
pointsMax = meta.pointsMax, pointsMax = meta.pointsMax,
)) ))
} }
@@ -147,9 +149,9 @@ class LabService(
lab.title = request.title.trim() lab.title = request.title.trim()
lab.deadlineAt = parseInstant(request.deadlineAt) lab.deadlineAt = parseInstant(request.deadlineAt)
lab.defenseAt = parseInstant(request.defenseAt) lab.defenseAt = parseInstant(request.defenseAt)
lab.pointsMax = request.pointsMax lab.pointsMax = request.pointsMax ?: lab.pointsMax
lab.status = request.status lab.status = request.status ?: lab.status
lab.descriptionMd = request.descriptionMd lab.descriptionMd = request.descriptionMd.orEmpty()
labs.save(lab) labs.save(lab)
return detail(labId) return detail(labId)
} }
@@ -159,10 +161,10 @@ class LabService(
val variant = variantsRepo.findById(variantId) val variant = variantsRepo.findById(variantId)
.orElseThrow { NotFoundException("Вариант не найден") } .orElseThrow { NotFoundException("Вариант не найден") }
if (variant.lab?.id != labId) throw 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.number = request.number.trim()
variant.title = request.title.trim() variant.title = request.title.orEmpty().trim()
variant.statementMd = request.statementMd variant.statementMd = request.statementMd.orEmpty()
request.labLevelId.takeIf { it.isNotBlank() }?.let { levelId -> request.labLevelId.takeIf { it.isNotBlank() }?.let { levelId ->
variant.labLevel = labLevels.findById(UUID.fromString(levelId)) variant.labLevel = labLevels.findById(UUID.fromString(levelId))
.orElseThrow { BadRequestException("Уровень не найден") } .orElseThrow { BadRequestException("Уровень не найден") }
@@ -174,15 +176,15 @@ class LabService(
@Transactional @Transactional
fun addVariant(labId: UUID, request: VariantSaveRequest): LabDetail { fun addVariant(labId: UUID, request: VariantSaveRequest): LabDetail {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") } 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)) val level = labLevels.findById(UUID.fromString(request.labLevelId))
.orElseThrow { BadRequestException("Уровень не найден") } .orElseThrow { BadRequestException("Уровень не найден") }
variantsRepo.save(Variant( variantsRepo.save(Variant(
lab = lab, lab = lab,
labLevel = level, labLevel = level,
number = request.number.trim(), number = request.number.trim(),
title = request.title.trim(), title = request.title.orEmpty().trim(),
statementMd = request.statementMd, statementMd = request.statementMd.orEmpty(),
)) ))
return detail(labId) return detail(labId)
} }
@@ -196,6 +198,88 @@ class LabService(
return detail(labId) 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? { private fun parseInstant(value: String?): Instant? {
val v = value?.takeIf { it.isNotBlank() } ?: return null val v = value?.takeIf { it.isNotBlank() } ?: return null
return runCatching { Instant.parse(v) } return runCatching { Instant.parse(v) }

View File

@@ -0,0 +1,24 @@
package ru.ysu.lms.academic.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.academic.domain.ContentArticle
import ru.ysu.lms.academic.domain.ContentChapter
import ru.ysu.lms.academic.domain.ContentSection
import java.util.UUID
interface ContentChapterRepository : JpaRepository<ContentChapter, UUID> {
fun findAllByOrderByPositionAscIdAsc(): List<ContentChapter>
fun findBySlug(slug: String): ContentChapter?
}
interface ContentSectionRepository : JpaRepository<ContentSection, UUID> {
fun findAllByOrderByPositionAscIdAsc(): List<ContentSection>
fun findByChapterIdOrderByPositionAscIdAsc(chapterId: UUID): List<ContentSection>
fun findByChapterIdAndSlug(chapterId: UUID, slug: String): ContentSection?
}
interface ContentArticleRepository : JpaRepository<ContentArticle, UUID> {
fun findBySectionIdOrderByPositionAscIdAsc(sectionId: UUID): List<ContentArticle>
fun findBySectionIdAndSlug(sectionId: UUID, slug: String): ContentArticle?
fun findAllByOrderByPositionAscIdAsc(): List<ContentArticle>
}

View File

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

View File

@@ -43,7 +43,14 @@ class SecurityConfig {
.cors { it.disable() } .cors { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth -> .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() auth.anyRequest().authenticated()
} }
.oauth2ResourceServer { oauth -> .oauth2ResourceServer { oauth ->

View File

@@ -35,3 +35,7 @@ management:
springdoc: springdoc:
api-docs: api-docs:
path: /v3/api-docs path: /v3/api-docs
lms:
content:
assets-dir: ${LMS_CONTENT_ASSETS_DIR:data/content-assets}

View File

@@ -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;

View File

@@ -45,7 +45,8 @@ class JwtAuthenticationFilter(private val jwtDecoder: JwtDecoder) : GlobalFilter
path == "/api/v1/auth/login" || path == "/api/v1/auth/login" ||
path == "/api/v1/auth/setup" || path == "/api/v1/auth/setup" ||
path == "/api/v1/auth/health" || 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? { private fun bearerToken(exchange: ServerWebExchange): String? {
val header = exchange.request.headers.getFirst("Authorization") ?: return null val header = exchange.request.headers.getFirst("Authorization") ?: return null

View File

@@ -16,7 +16,7 @@ spring:
- id: academic - id: academic
uri: ${LMS_ACADEMIC_URI:http://localhost:8082} uri: ${LMS_ACADEMIC_URI:http://localhost:8082}
predicates: 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 - id: progress
uri: ${LMS_PROGRESS_URI:http://localhost:8083} uri: ${LMS_PROGRESS_URI:http://localhost:8083}
predicates: predicates:
@@ -26,7 +26,7 @@ management:
endpoints: endpoints:
web: web:
exposure: exposure:
include: health,info,gateway include: health,info
endpoint: endpoint:
health: health:
probes: probes:

View File

@@ -29,6 +29,12 @@ class GroupController(private val groupService: GroupService) {
@PostMapping @PostMapping
fun create(@Valid @RequestBody body: CreateGroupRequest): GroupSummary = groupService.create(body) 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") @PostMapping("/import")
fun import( fun import(
@RequestParam("file") file: MultipartFile, @RequestParam("file") file: MultipartFile,

View File

@@ -31,6 +31,44 @@ class GroupService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun list(): List<GroupSummary> = groups.findAllByOrderByNameAsc().map { summary(it) } fun list(): List<GroupSummary> = groups.findAllByOrderByNameAsc().map { summary(it) }
@Transactional
fun createStudent(groupId: UUID, request: CreateStudentRequest): Credential {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val studentNo = request.studentNo.trim()
if (studentNo.isBlank()) throw BadRequestException("Студенческий номер не может быть пустым")
if (students.existsByStudentNo(studentNo)) {
throw ConflictException("Студент с номером $studentNo уже существует")
}
val password = Passwords.generate()
val account = accounts.save(
Account(
login = studentNo,
passwordHash = passwordEncoder.encode(password) ?: "",
role = LmsRole.STUDENT,
mustChangePassword = true,
tempPassword = password,
),
)
val student = students.save(
Student(
studentNo = studentNo,
lastName = request.lastName.trim(),
firstName = request.firstName.trim(),
middleName = request.middleName.trim(),
basis = request.basis.trim(),
note = request.note.trim(),
),
)
student.account = account
enrollments.save(Enrollment(group = group, student = student))
return Credential(
id = account.id.toString(),
studentNo = studentNo,
fullName = student.fullName,
password = password,
)
}
@Transactional @Transactional
fun create(request: CreateGroupRequest): GroupSummary { fun create(request: CreateGroupRequest): GroupSummary {
val name = request.name.trim() val name = request.name.trim()

View File

@@ -14,6 +14,17 @@ data class CreateGroupRequest(
val name: String, 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( data class StudentRow(
val id: String, val id: String,
val studentNo: String, val studentNo: String,

View File

@@ -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.ApiClient
import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.AssignVariantPayload 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.GradeSetPayload
import ru.ysu.lms.web.api.LabCreatePayload
import ru.ysu.lms.web.api.LabImportResultDto import ru.ysu.lms.web.api.LabImportResultDto
import ru.ysu.lms.web.api.LabUpdatePayload 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.VariantSavePayload
import ru.ysu.lms.web.api.DistributeResultDto import ru.ysu.lms.web.api.DistributeResultDto
import ru.ysu.lms.web.api.LevelAssignPayload import ru.ysu.lms.web.api.LevelAssignPayload
@@ -30,14 +34,31 @@ import java.nio.charset.StandardCharsets
@Controller @Controller
@RequestMapping("/admin") @RequestMapping("/admin")
class AdminController(private val api: ApiClient) { class AdminController(
private val api: ApiClient,
private val markdown: ru.ysu.lms.markdown.MarkdownRenderer,
) {
@GetMapping @GetMapping
fun dashboard(session: HttpSession, model: Model): String { 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("groupCount", groups.size)
model.addAttribute("studentCount", groups.sumOf { it.studentCount }) 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" return "admin/dashboard"
} }
@@ -247,6 +268,9 @@ class AdminController(private val api: ApiClient) {
} }
model.addAttribute("lab", lab) model.addAttribute("lab", lab)
model.addAttribute("groups", groupSections) 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" return "admin/lab"
} }
@@ -266,6 +290,116 @@ class AdminController(private val api: ApiClient) {
"redirect:/admin/labs/$labId" "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") @GetMapping("/labs/{id}/edit")
fun editLabForm( fun editLabForm(
@PathVariable id: String, @PathVariable id: String,

View File

@@ -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, "Сессия истекла, войдите заново")
}

View File

@@ -291,6 +291,217 @@ class ApiClient(
.retrieve().body(GradeDto::class.java) .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<ContentArticleDto> {
val type = object : ParameterizedTypeReference<List<ContentArticleDto>>() {}
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<String, Any>()
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<String, Any> {
val type = object : ParameterizedTypeReference<Map<String, Any>>() {}
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<String, Any> {
val type = object : ParameterizedTypeReference<Map<String, Any>>() {}
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 { private companion object {
val DETAIL_PATTERN = Regex("\"detail\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"") val DETAIL_PATTERN = Regex("\"detail\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"")
} }

View File

@@ -213,6 +213,105 @@ data class GradeSetPayload(
val score: String, 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<LevelPayload>,
)
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<ContentChapterNodeDto> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ContentChapterNodeDto(
val id: String = "",
val title: String = "",
val slug: String = "",
val position: Int? = 0,
val sections: List<ContentSectionNodeDto> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ContentSectionNodeDto(
val id: String = "",
val title: String = "",
val slug: String = "",
val position: Int? = 0,
val articles: List<ContentArticleNodeDto> = 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( class DownloadDto(
val bytes: ByteArray, val bytes: ByteArray,
val filename: String, val filename: String,

View File

@@ -1,5 +1,6 @@
package ru.ysu.lms.web.auth package ru.ysu.lms.web.auth
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpSession import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller import org.springframework.stereotype.Controller
import org.springframework.ui.Model import org.springframework.ui.Model
@@ -32,13 +33,13 @@ class AuthPageController(private val api: ApiClient) {
@PostMapping("/login") @PostMapping("/login")
fun login( fun login(
request: HttpServletRequest,
@RequestParam login: String, @RequestParam login: String,
@RequestParam password: String, @RequestParam password: String,
session: HttpSession,
model: Model, model: Model,
): String = try { ): String = try {
val auth = api.login(login.trim(), password) val auth = api.login(login.trim(), password)
store(session, auth) store(request, auth)
redirectFor(auth) redirectFor(auth)
} catch (ex: ApiException) { } catch (ex: ApiException) {
model.addAttribute("adminExists", api.adminExists()) model.addAttribute("adminExists", api.adminExists())
@@ -57,14 +58,14 @@ class AuthPageController(private val api: ApiClient) {
@PostMapping("/setup") @PostMapping("/setup")
fun setup( fun setup(
request: HttpServletRequest,
@RequestParam login: String, @RequestParam login: String,
@RequestParam password: String, @RequestParam password: String,
@RequestParam password2: String, @RequestParam password2: String,
session: HttpSession,
model: Model, model: Model,
): String = try { ): String = try {
val auth = api.setup(login.trim(), password, password2) val auth = api.setup(login.trim(), password, password2)
store(session, auth) store(request, auth)
"redirect:/admin" "redirect:/admin"
} catch (ex: ApiException) { } catch (ex: ApiException) {
model.addAttribute("error", ex.message) model.addAttribute("error", ex.message)
@@ -104,7 +105,10 @@ class AuthPageController(private val api: ApiClient) {
private fun currentUser(session: HttpSession): SessionUser? = private fun currentUser(session: HttpSession): SessionUser? =
session.getAttribute(SessionKeys.USER) as? 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.TOKEN, auth.token)
session.setAttribute( session.setAttribute(
SessionKeys.USER, SessionKeys.USER,

View File

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

View File

@@ -15,9 +15,11 @@ import ru.ysu.lms.web.session.SessionUser
class WebConfig( class WebConfig(
private val authInterceptor: AuthInterceptor, private val authInterceptor: AuthInterceptor,
private val adminInterceptor: AdminInterceptor, private val adminInterceptor: AdminInterceptor,
private val csrfInterceptor: CsrfInterceptor,
) : WebMvcConfigurer { ) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) { override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(csrfInterceptor).addPathPatterns("/**")
registry.addInterceptor(authInterceptor) registry.addInterceptor(authInterceptor)
.addPathPatterns("/admin/**", "/student/**", "/change-password") .addPathPatterns("/admin/**", "/student/**", "/change-password")
registry.addInterceptor(adminInterceptor) registry.addInterceptor(adminInterceptor)

View File

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

View File

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

View File

@@ -1,5 +1,10 @@
server: server:
port: ${SERVER_PORT:8090} port: ${SERVER_PORT:8090}
servlet:
session:
cookie:
http-only: true
same-site: lax
spring: spring:
application: application:

View File

@@ -167,6 +167,27 @@ tr:last-child td { border-bottom: none; }
.filter-select { width: auto; } .filter-select { width: auto; }
.hidden { display: none !important; } .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 */ /* Markdown body */
.md-body { font-size: 0.9375rem; } .md-body { font-size: 0.9375rem; }
.md-body h1, .md-body h2, .md-body h3 { margin: 1rem 0 0.5rem; line-height: 1.3; } .md-body h1, .md-body h2, .md-body h3 { margin: 1rem 0 0.5rem; line-height: 1.3; }

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="6" fill="#6366f1"/>
<path d="M9 8v13h5l-1.5 3H6V8z" fill="#eef2ff"/>
<path d="M23 8v13h-5l1.5 3h5V8z" fill="#c7d2fe"/>
<path d="M11 9c2-1.5 4-1.5 5 0 1-1.5 3-1.5 5 0v10c-2-1.5-4-1.5-5 0-1-1.5-3-1.5-5 0z" fill="#ffffff"/>
<path d="M16 9v10" stroke="#6366f1" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 399 B

View File

@@ -4,26 +4,53 @@
<body> <body>
<div id="content"> <div id="content">
<h1 class="page-title">Панель преподавателя</h1> <h1 class="page-title">Панель преподавателя</h1>
<div class="grid grid-stats"> <div class="grid grid-stats">
<div class="card card-body"><p class="stat-title">Группы</p><p class="stat-value" th:text="${groupCount}">0</p></div> <div class="card card-body"><p class="stat-title">Группы</p><p class="stat-value" th:text="${groupCount}">0</p></div>
<div class="card card-body"><p class="stat-title">Студенты</p><p class="stat-value" th:text="${studentCount}">0</p></div> <div class="card card-body"><p class="stat-title">Студенты</p><p class="stat-value" th:text="${studentCount}">0</p></div>
<div class="card card-body"><p class="stat-title">Лабораторные</p><p class="stat-value text-muted">скоро</p></div> <div class="card card-body"><p class="stat-title">Лабораторные</p><p class="stat-value" th:text="${labCount}">0</p>
<p class="hint" th:if="${labCount > 0}">опубликовано: <span th:text="${publishedLabs}">0</span></p></div>
<div class="card card-body"><p class="stat-title">Средний балл</p>
<p class="stat-value" th:if="${avgGrade != null}" th:text="${#numbers.formatDecimal(avgGrade, 1, 2)}">—</p>
<p class="stat-value text-muted" th:if="${avgGrade == null}">—</p></div>
</div> </div>
<div class="mt-8">
<div class="grid grid-2 mt-8">
<div>
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="section-title">Группы</h2> <h2 class="section-title">Лабораторные</h2>
<a th:href="@{/admin/groups}" class="text-sm">Все группы →</a> <a th:href="@{/admin/labs}" class="text-sm">Все →</a>
</div> </div>
<div class="card card-flush"> <div class="card card-flush">
<table> <table>
<thead><tr><th>Название</th><th>Студентов</th></tr></thead>
<tbody> <tbody>
<tr th:each="group : ${groups}"><td><a th:href="@{/admin/groups/{id}(id=${group.id})}" th:text="${group.name}">—</a></td><td th:text="${group.studentCount}">0</td></tr> <tr th:each="lab : ${labs}">
<tr th:if="${#lists.isEmpty(groups)}"><td colspan="2" class="empty">Групп пока нет.</td></tr> <td><a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}. ${lab.title}|">—</a></td>
<td class="text-muted" th:text="${lab.variantCount} + ' вар.'">—</td>
</tr>
<tr th:if="${#lists.isEmpty(labs)}"><td class="empty">Лабораторных нет.</td></tr>
</tbody>
</table>
</div>
</div>
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="section-title">Группы</h2>
<a th:href="@{/admin/groups}" class="text-sm">Все →</a>
</div>
<div class="card card-flush">
<table>
<tbody>
<tr th:each="group : ${groups}">
<td><a th:href="@{/admin/groups/{id}(id=${group.id})}" th:text="${group.name}">—</a></td>
<td class="text-muted" th:text="${group.studentCount} + ' студ.'">—</td>
</tr>
<tr th:if="${#lists.isEmpty(groups)}"><td class="empty">Групп нет.</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
</div>
</body> </body>
</html> </html>

View File

@@ -10,6 +10,7 @@
<p class="text-sm text-muted mt-1">Студентов: <span th:text="${#lists.size(group.students)}">0</span></p> <p class="text-sm text-muted mt-1">Студентов: <span th:text="${#lists.size(group.students)}">0</span></p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button class="btn btn-primary" type="button" onclick="openModal('modal-add-student')">+ Студент</button>
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Выгрузить XLSX</a> <a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Выгрузить XLSX</a>
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='csv')}">CSV</a> <a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='csv')}">CSV</a>
</div> </div>
@@ -67,6 +68,7 @@
<input class="input input-sm input-score" name="score" type="number" step="0.1" min="0" max="5" <input class="input input-sm input-score" name="score" type="number" step="0.1" min="0" max="5"
th:value="${score}"> th:value="${score}">
<button class="btn btn-secondary btn-sm" type="submit">OK</button> <button class="btn btn-secondary btn-sm" type="submit">OK</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>
</td> </td>
<td class="text-center font-semibold"> <td class="text-center font-semibold">
@@ -95,7 +97,8 @@
<span th:unless="${student.mustChangePassword}" class="badge badge-ok">изменён</span></td> <span th:unless="${student.mustChangePassword}" class="badge badge-ok">изменён</span></td>
<td class="text-center"> <td class="text-center">
<form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${student.id})}" method="post"> <form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${student.id})}" method="post">
<button class="nav-link text-sm" type="submit">Сбросить</button> <button class="btn btn-danger btn-sm" type="submit">Сбросить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>
</td> </td>
</tr> </tr>
@@ -104,5 +107,42 @@
</table> </table>
</div> </div>
</div> </div>
<div th:if="${createdStudent}" class="card import-card toast">
<div class="card-body">
<h2 class="section-title">Студент добавлен</h2>
<p class="text-sm" th:text="${createdStudent.fullName}">—</p>
<p class="text-sm">Логин: <code class="code font-mono" th:text="${createdStudent.studentNo}">—</code></p>
<p class="text-sm">Пароль: <code class="code font-mono font-semibold" th:text="${createdStudent.password}">—</code></p>
</div>
</div>
<dialog id="modal-add-student" class="modal">
<div class="modal-header">
<h2 class="modal-title">Добавить студента</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-add-student')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/groups/{gid}/students/create(gid=${group.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<div class="grid grid-2">
<div><label class="label" for="stNo">Студенческий номер (логин)</label>
<input class="input" id="stNo" name="studentNo" required></div>
<div><label class="label" for="stBasis">Основа обучения</label>
<input class="input" id="stBasis" name="basis" placeholder="бюджет"></div>
</div>
<div><label class="label" for="stLast">Фамилия</label>
<input class="input" id="stLast" name="lastName" required></div>
<div class="grid grid-2">
<div><label class="label" for="stFirst">Имя</label>
<input class="input" id="stFirst" name="firstName"></div>
<div><label class="label" for="stMiddle">Отчество</label>
<input class="input" id="stMiddle" name="middleName"></div>
</div>
<p class="hint">Пароль будет сгенерирован автоматически.</p>
<button class="btn btn-primary" type="submit">Добавить</button>
</form>
</div>
</dialog>
</body> </body>
</html> </html>

View File

@@ -11,6 +11,7 @@
<h2 class="section-title">Создать группу</h2> <h2 class="section-title">Создать группу</h2>
<div><label class="label" for="name">Название</label><input class="input" id="name" name="name" placeholder="ПИ-11БО" required></div> <div><label class="label" for="name">Название</label><input class="input" id="name" name="name" placeholder="ПИ-11БО" required></div>
<button class="btn btn-primary" type="submit">Создать</button> <button class="btn btn-primary" type="submit">Создать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>
<form class="card form-card form-stack" th:action="@{/admin/groups/import}" method="post" enctype="multipart/form-data"> <form class="card form-card form-stack" th:action="@{/admin/groups/import}" method="post" enctype="multipart/form-data">
<h2 class="section-title">Импорт студентов</h2> <h2 class="section-title">Импорт студентов</h2>
@@ -18,6 +19,7 @@
<div><label class="label" for="file">Файл со списком</label><input class="input" id="file" name="file" type="file" accept=".xls,.xlsx,.csv" required></div> <div><label class="label" for="file">Файл со списком</label><input class="input" id="file" name="file" type="file" accept=".xls,.xlsx,.csv" required></div>
<div><label class="label" for="groupName">Название группы (необязательно)</label><input class="input" id="groupName" name="name" placeholder="Возьмём из файла"></div> <div><label class="label" for="groupName">Название группы (необязательно)</label><input class="input" id="groupName" name="name" placeholder="Возьмём из файла"></div>
<button class="btn btn-primary" type="submit">Импортировать</button> <button class="btn btn-primary" type="submit">Импортировать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>
</div> </div>
<div class="card card-flush mt-8"> <div class="card card-flush mt-8">

View File

@@ -34,6 +34,7 @@
<textarea class="input" id="descriptionMd" name="descriptionMd" rows="14" <textarea class="input" id="descriptionMd" name="descriptionMd" rows="14"
th:text="${lab.descriptionMd}"></textarea></div> th:text="${lab.descriptionMd}"></textarea></div>
<button class="btn btn-primary" type="submit">Сохранить</button> <button class="btn btn-primary" type="submit">Сохранить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>
<h2 class="section-title mt-8">Варианты</h2> <h2 class="section-title mt-8">Варианты</h2>
@@ -52,10 +53,12 @@
<div class="flex gap-2"> <div class="flex gap-2">
<button class="btn btn-primary" type="submit">Сохранить</button> <button class="btn btn-primary" type="submit">Сохранить</button>
</div> </div>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>
<form th:action="@{/admin/labs/{labId}/variants/{vid}/delete(labId=${lab.id}, vid=${v.id})}" method="post" <form th:action="@{/admin/labs/{labId}/variants/{vid}/delete(labId=${lab.id}, vid=${v.id})}" method="post"
onsubmit="return confirm('Удалить вариант?')"> onsubmit="return confirm('Удалить вариант?')">
<button class="btn btn-secondary btn-sm" type="submit">Удалить вариант</button> <button class="btn btn-secondary btn-sm" type="submit">Удалить вариант</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>
</div> </div>
</div> </div>
@@ -75,6 +78,7 @@
<div><label class="label" for="newStatement">Условие (markdown)</label> <div><label class="label" for="newStatement">Условие (markdown)</label>
<textarea class="input" id="newStatement" name="statementMd" rows="6"></textarea></div> <textarea class="input" id="newStatement" name="statementMd" rows="6"></textarea></div>
<button class="btn btn-primary" type="submit">Добавить</button> <button class="btn btn-primary" type="submit">Добавить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>
</div> </div>
</body> </body>

View File

@@ -6,39 +6,83 @@
<a th:href="@{/admin/labs}" class="nav-link">← Все лабораторные</a> <a th:href="@{/admin/labs}" class="nav-link">← Все лабораторные</a>
<div class="flex flex-wrap items-center justify-between gap-4 mt-4 mb-6"> <div class="flex flex-wrap items-center justify-between gap-4 mt-4 mb-6">
<div> <div>
<h1 class="page-title" th:text="|${lab.number}. ${lab.title}|">ЛР 1</h1> <h1 class="page-title" th:text="|ЛР ${lab.number}. ${lab.title}|">ЛР 1</h1>
<p class="text-sm text-muted">Статус: <span class="badge" th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</span></p> <p class="text-sm text-muted">
Статус: <span class="badge" th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</span>
<span th:if="${lab.deadlineAt != null}"> · Срок: <span th:text="${lab.deadlineAt}"></span></span>
<span th:if="${lab.defenseAt != null}"> · Защита: <span th:text="${lab.defenseAt}"></span></span>
· Максимум: <span th:text="${lab.pointsMax}"></span> б.
</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<a class="btn btn-secondary" th:href="@{/admin/labs/{id}/edit(id=${lab.id})}">Редактировать</a> <button class="btn btn-secondary" type="button" onclick="openModal('modal-meta')">Править описание</button>
<form th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post"> <form th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post">
<button class="btn btn-primary" type="submit" name="force" value="false">Распределить</button> <button class="btn btn-primary" type="submit" name="force" value="false">Распределить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>
<form th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post"> <form th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post">
<button class="btn btn-secondary" type="submit" name="force" value="true">Перераспределить</button> <button class="btn btn-secondary" type="submit" name="force" value="true">Перераспределить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>
</div> </div>
</div> </div>
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div> <div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div> <div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
<h2 class="section-title">Уровни вариантов</h2> <h2 class="section-title">Условие</h2>
<div class="card card-flush mt-2 mb-6"> <div class="card card-body md-body mb-6" th:utext="${descriptionHtml}"></div>
<div class="flex items-center justify-between gap-4 mb-4">
<h2 class="section-title">Уровни и варианты</h2>
<button class="btn btn-secondary" type="button" onclick="openModal('modal-add-level')">+ Уровень</button>
</div>
<div th:each="level : ${lab.levels}" class="card mb-4">
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-4 mb-4">
<h3 class="group-title">
<span class="font-mono text-muted" th:text="${level.code}">basic</span> —
<span th:text="${level.title}">Уровень</span>
(<span th:text="${#lists.size(level.variants)}">0</span> вар.)
</h3>
<div class="flex gap-2">
<button class="btn btn-secondary btn-sm" type="button"
th:onclick="openModal('modal-rename-level-'+${level.id})">Переименовать</button>
<form th:action="@{/admin/labs/{labId}/levels/{lid}/delete(labId=${lab.id}, lid=${level.id})}" method="post"
onsubmit="return confirm('Удалить уровень без вариантов?')">
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
<button class="btn btn-primary btn-sm" type="button"
th:onclick="openModal('modal-add-variant-'+${level.id})">+ Вариант</button>
</div>
</div>
<table> <table>
<thead><tr><th>Код</th><th>Название</th><th>Вариантов</th></tr></thead> <thead><tr><th>№</th><th>Название</th><th>Условие</th><th></th></tr></thead>
<tbody> <tbody>
<tr th:each="level : ${lab.levels}"> <tr th:each="v : ${level.variants}">
<td class="font-mono" th:text="${level.code}">—</td> <td class="font-mono" th:text="${v.number}">—</td>
<td th:text="${level.title}">—</td> <td th:text="${v.title}">—</td>
<td th:text="${#lists.size(level.variants)}">0</td> <td class="text-muted" th:text="${#strings.abbreviate(v.statementMd, 60)}">—</td>
<td class="text-center">
<button class="btn btn-secondary btn-sm" type="button"
th:onclick="openModal('modal-edit-variant-'+${v.id})">Править</button>
<form class="inline-form" th:action="@{/admin/labs/{labId}/variants/{vid}/delete(labId=${lab.id}, vid=${v.id})}" method="post"
onsubmit="return confirm('Удалить вариант?')">
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</td>
</tr> </tr>
<tr th:if="${#lists.isEmpty(lab.levels)}"><td colspan="3" class="empty">Уровни не найдены.</td></tr> <tr th:if="${#lists.isEmpty(level.variants)}"><td colspan="4" class="empty">Вариантов пока нет.</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<div class="flex items-center justify-between gap-4 mb-4">
<h2 class="section-title">Студенты и варианты</h2> <h2 class="section-title">Студенты и варианты</h2>
<div class="flex items-center justify-between gap-4 mb-4">
<p class="text-sm text-muted">Уровень по умолчанию — самый простой. Селекты: выдать вариант посложнее или назначить вручную.</p>
<label class="text-sm text-muted">Группа: <label class="text-sm text-muted">Группа:
<select id="group-filter" class="input filter-select" onchange="filterGroups(this.value)"> <select id="group-filter" class="input filter-select" onchange="filterGroups(this.value)">
<option value="">Все</option> <option value="">Все</option>
@@ -46,8 +90,6 @@
</select> </select>
</label> </label>
</div> </div>
<p class="text-sm text-muted mb-4">Уровень по умолчанию — самый простой. Селекты позволяют выдать вариант посложнее или назначить вариант вручную.</p>
<div class="group-section" th:each="g : ${groups}" th:data-group="${g.groupName}"> <div class="group-section" th:each="g : ${groups}" th:data-group="${g.groupName}">
<h3 class="group-title" th:text="${g.groupName}">Группа</h3> <h3 class="group-title" th:text="${g.groupName}">Группа</h3>
<div class="card card-flush mb-6"> <div class="card card-flush mb-6">
@@ -65,6 +107,7 @@
th:selected="${level.id == s.levelId}">—</option> th:selected="${level.id == s.levelId}">—</option>
</select> </select>
<button class="btn btn-secondary btn-sm" type="submit">OK</button> <button class="btn btn-secondary btn-sm" type="submit">OK</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>
</td> </td>
<td> <td>
@@ -78,6 +121,7 @@
</optgroup> </optgroup>
</select> </select>
<button class="btn btn-secondary btn-sm" type="submit">OK</button> <button class="btn btn-secondary btn-sm" type="submit">OK</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>
</td> </td>
<td><span th:if="${s.variantNumber != null}" class="font-mono" th:text="${s.variantNumber}">—</span></td> <td><span th:if="${s.variantNumber != null}" class="font-mono" th:text="${s.variantNumber}">—</span></td>
@@ -88,6 +132,120 @@
</table> </table>
</div> </div>
</div> </div>
</div>
<dialog id="modal-meta" class="modal">
<div class="modal-header">
<h2 class="modal-title">Правка лабораторной</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-meta')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{id}/edit(id=${lab.id})}" method="post">
<div><label class="label" for="metaTitle">Название</label>
<input class="input" id="metaTitle" name="title" th:value="${lab.title}" required></div>
<div class="grid grid-2">
<div><label class="label" for="metaDeadline">Срок сдачи</label>
<input class="input" id="metaDeadline" name="deadlineAt" type="datetime-local" th:value="${deadlineLocal}"></div>
<div><label class="label" for="metaDefense">Дата защиты</label>
<input class="input" id="metaDefense" name="defenseAt" type="datetime-local" th:value="${defenseLocal}"></div>
</div>
<div class="grid grid-2">
<div><label class="label" for="metaPoints">Максимум баллов</label>
<input class="input" id="metaPoints" name="pointsMax" type="number" step="0.1" min="0" th:value="${lab.pointsMax}" required></div>
<div><label class="label" for="metaStatus">Статус</label>
<select class="input" id="metaStatus" name="status">
<option value="draft" th:selected="${lab.status == 'draft'}">draft</option>
<option value="published" th:selected="${lab.status == 'published'}">published</option>
<option value="archived" th:selected="${lab.status == 'archived'}">archived</option>
</select></div>
</div>
<div><label class="label" for="metaDesc">Условие (markdown)</label>
<textarea class="input" id="metaDesc" name="descriptionMd" rows="12" th:text="${lab.descriptionMd}"></textarea></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<dialog id="modal-add-level" class="modal">
<div class="modal-header">
<h2 class="modal-title">Добавить уровень</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-add-level')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/levels/save(labId=${lab.id})}" method="post">
<div class="grid grid-2">
<div><label class="label" for="lvlCode">Код</label>
<input class="input" id="lvlCode" name="code" placeholder="pro" required></div>
<div><label class="label" for="lvlTitle">Название</label>
<input class="input" id="lvlTitle" name="title" placeholder="Профессиональный" required></div>
</div>
<button class="btn btn-primary" type="submit">Добавить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<dialog th:each="level : ${lab.levels}" th:id="|modal-rename-level-${level.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Переименовать уровень</h2>
<button class="modal-close" type="button" th:onclick="closeModal('modal-rename-level-'+[[${level.id}]])">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/levels/save(labId=${lab.id})}" method="post">
<input type="hidden" name="levelId" th:value="${level.id}">
<input type="hidden" name="code" th:value="${level.code}">
<div><label class="label">Название</label>
<input class="input" name="title" th:value="${level.title}" required></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<dialog th:each="level : ${lab.levels}" th:id="|modal-add-variant-${level.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новый вариант — <span th:text="${level.title}">уровень</span></h2>
<button class="modal-close" type="button" th:onclick="closeModal('modal-add-variant-'+[[${level.id}]])">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
<input type="hidden" name="labLevelId" th:value="${level.id}">
<div class="grid grid-2">
<div><label class="label">Номер</label><input class="input" name="number" required></div>
<div><label class="label">Название</label><input class="input" name="title"></div>
</div>
<div><label class="label">Условие (markdown)</label>
<textarea class="input" name="statementMd" rows="8"></textarea></div>
<button class="btn btn-primary" type="submit">Добавить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<th:block th:each="level : ${lab.levels}">
<dialog th:each="v : ${level.variants}" th:id="|modal-edit-variant-${v.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Правка варианта</h2>
<button class="modal-close" type="button" th:onclick="closeModal('modal-edit-variant-'+[[${v.id}]])">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
<input type="hidden" name="variantId" th:value="${v.id}">
<input type="hidden" name="labLevelId" th:value="${level.id}">
<div class="grid grid-2">
<div><label class="label">Номер</label><input class="input" name="number" th:value="${v.number}" required></div>
<div><label class="label">Название</label><input class="input" name="title" th:value="${v.title}"></div>
</div>
<div><label class="label">Условие (markdown)</label>
<textarea class="input" name="statementMd" rows="8" th:text="${v.statementMd}"></textarea></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
</th:block>
<script> <script>
function filterGroups(name) { function filterGroups(name) {
document.querySelectorAll('.group-section').forEach(function (el) { document.querySelectorAll('.group-section').forEach(function (el) {
@@ -95,6 +253,5 @@
}); });
} }
</script> </script>
</div>
</body> </body>
</html> </html>

View File

@@ -3,18 +3,14 @@
<head><title>Лабораторные — LMS</title></head> <head><title>Лабораторные — LMS</title></head>
<body> <body>
<div id="content"> <div id="content">
<div class="flex items-center justify-between gap-4 mb-6">
<h1 class="page-title">Лабораторные</h1> <h1 class="page-title">Лабораторные</h1>
<button class="btn btn-primary" type="button" onclick="openModal('modal-create-lab')">+ Создать</button>
</div>
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div> <div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div> <div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
<div class="grid grid-2">
<form class="card form-card form-stack" th:action="@{/admin/labs/import}" method="post" enctype="multipart/form-data"> <div class="card card-flush">
<h2 class="section-title">Импорт лабораторной</h2>
<p class="text-sm text-muted">Принимаются .zip (lab.yaml + README.md + variants/*.md) или .json.</p>
<div><label class="label" for="labFile">Файл</label><input class="input" id="labFile" name="file" type="file" accept=".zip,.json" required></div>
<button class="btn btn-primary" type="submit">Импортировать</button>
</form>
</div>
<div class="card card-flush mt-8">
<table> <table>
<thead><tr><th>№</th><th>Название</th><th>Вариантов</th><th>Статус</th><th></th></tr></thead> <thead><tr><th>№</th><th>Название</th><th>Вариантов</th><th>Статус</th><th></th></tr></thead>
<tbody> <tbody>
@@ -23,12 +19,49 @@
<td><a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="${lab.title}">—</a></td> <td><a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="${lab.title}">—</a></td>
<td th:text="${lab.variantCount}">0</td> <td th:text="${lab.variantCount}">0</td>
<td><span class="badge" th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</span></td> <td><span class="badge" th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</span></td>
<td class="text-center"><form th:action="@{/admin/labs/{id}/delete(id=${lab.id})}" method="post"><button class="nav-link text-sm" type="submit">Удалить</button></form></td> <td class="text-center"><form th:action="@{/admin/labs/{id}/delete(id=${lab.id})}" method="post"><input type="hidden" name="_csrf" th:value="${csrfToken}"><button class="btn btn-danger btn-sm" type="submit">Удалить</button></form></td>
</tr> </tr>
<tr th:if="${#lists.isEmpty(labs)}"><td colspan="5" class="empty">Лабораторных пока нет.</td></tr> <tr th:if="${#lists.isEmpty(labs)}"><td colspan="5" class="empty">Лабораторных пока нет.</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<dialog id="modal-create-lab" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новая лабораторная</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-create-lab')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/create}" method="post">
<div class="grid grid-2">
<div><label class="label" for="labNumber">Номер</label>
<input class="input" id="labNumber" name="number" type="number" min="1" required></div>
<div><label class="label" for="labPoints">Максимум баллов</label>
<input class="input" id="labPoints" name="pointsMax" type="number" step="0.1" min="0" value="5" required></div>
</div>
<div><label class="label" for="labTitle">Название</label>
<input class="input" id="labTitle" name="title" required></div>
<div class="grid grid-2">
<div><label class="label" for="labDeadline">Срок сдачи</label>
<input class="input" id="labDeadline" name="deadlineAt" type="datetime-local"></div>
<div><label class="label" for="labDefense">Дата защиты</label>
<input class="input" id="labDefense" name="defenseAt" type="datetime-local"></div>
</div>
<div><label class="label" for="labStatus">Статус</label>
<select class="input" id="labStatus" name="status">
<option value="draft">draft</option>
<option value="published">published</option>
</select></div>
<div><label class="label" for="labLevels">Уровни (по одному на строку: код; название)</label>
<textarea class="input" id="labLevels" name="levels" rows="4" placeholder="basic; Основная группа&#10;advanced; Повышенная сложность"></textarea>
<p class="hint">Если оставить пустым, будет создан уровень «Основная группа». Варианты добавите на странице лабы.</p></div>
<div><label class="label" for="labDesc">Условие (markdown, можно позже)</label>
<textarea class="input" id="labDesc" name="descriptionMd" rows="6"></textarea></div>
<button class="btn btn-primary" type="submit">Создать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
</body> </body>
</html> </html>

View File

@@ -12,6 +12,7 @@
<div><label class="label" for="new">Новый пароль</label><input class="input" id="new" name="new" type="password" required><p class="hint">Минимум 8 символов.</p></div> <div><label class="label" for="new">Новый пароль</label><input class="input" id="new" name="new" type="password" required><p class="hint">Минимум 8 символов.</p></div>
<div><label class="label" for="new2">Повторите новый пароль</label><input class="input" id="new2" name="new2" type="password" required></div> <div><label class="label" for="new2">Повторите новый пароль</label><input class="input" id="new2" name="new2" type="password" required></div>
<button class="btn btn-primary" type="submit">Сохранить</button> <button class="btn btn-primary" type="submit">Сохранить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>
</div> </div>
</div> </div>

View File

@@ -4,11 +4,14 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title th:replace="${title}">LMS</title> <title th:replace="${title}">LMS</title>
<link rel="icon" type="image/svg+xml" th:href="@{/favicon.svg}">
<link rel="stylesheet" th:href="@{/css/app.css}"> <link rel="stylesheet" th:href="@{/css/app.css}">
<script th:src="@{/js/htmx.min.js}" defer></script> <script th:src="@{/js/htmx.min.js}" defer></script>
<script> <script>
(function(){ var t = localStorage.getItem('lms-theme') || 'light'; document.documentElement.setAttribute('data-theme', t); })(); (function(){ var t = localStorage.getItem('lms-theme') || 'light'; document.documentElement.setAttribute('data-theme', t); })();
function toggleTheme(){ var e=document.documentElement; var t=e.getAttribute('data-theme')==='dark'?'light':'dark'; e.setAttribute('data-theme',t); localStorage.setItem('lms-theme',t); } function toggleTheme(){ var e=document.documentElement; var t=e.getAttribute('data-theme')==='dark'?'light':'dark'; e.setAttribute('data-theme',t); localStorage.setItem('lms-theme',t); }
function openModal(id){ var d=document.getElementById(id); if(d) d.showModal(); }
function closeModal(id){ var d=document.getElementById(id); if(d) d.close(); }
</script> </script>
</head> </head>
<body> <body>
@@ -16,13 +19,17 @@
<header class="page-header"> <header class="page-header">
<div class="page-header-inner"> <div class="page-header-inner">
<a th:href="@{/}" class="logo">LMS</a> <a th:href="@{/}" class="logo">LMS</a>
<nav class="nav" th:if="${currentUser != null}"> <nav class="nav">
<a th:href="@{/learn}" class="nav-link">Материалы</a>
<th:block th:if="${currentUser != null}">
<a th:if="${currentUser.admin}" th:href="@{/admin/groups}" class="nav-link">Группы</a> <a th:if="${currentUser.admin}" th:href="@{/admin/groups}" class="nav-link">Группы</a>
<a th:if="${currentUser.admin}" th:href="@{/admin/labs}" class="nav-link">Лабораторные</a> <a th:if="${currentUser.admin}" th:href="@{/admin/labs}" class="nav-link">Лабораторные</a>
<span class="nav-sep" th:text="${currentUser.name}">—</span> <span class="nav-sep" th:text="${currentUser.name}">—</span>
</th:block>
<button onclick="toggleTheme()" class="btn btn-secondary btn-sm">☀/🌙</button> <button onclick="toggleTheme()" class="btn btn-secondary btn-sm">☀/🌙</button>
<form th:action="@{/logout}" method="post"> <form th:if="${currentUser != null}" th:action="@{/logout}" method="post">
<button class="btn btn-secondary" type="submit">Выйти</button> <button class="btn btn-secondary" type="submit">Выйти</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>
</nav> </nav>
</div> </div>

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title th:text="${article.title} + ' — LMS'">Статья</title></head>
<body>
<div id="content">
<nav class="text-sm mb-4">
<a th:href="@{/learn}" class="nav-link">Материалы</a>
<span class="nav-sep mx-2">/</span>
<a th:href="@{/learn/{c}(c=${article.chapterSlug})}" class="nav-link" th:text="${article.chapterTitle}">Глава</a>
<span class="nav-sep mx-2">/</span>
<a th:href="@{/learn/{c}/{s}(c=${article.chapterSlug}, s=${article.sectionSlug})}" class="nav-link"
th:text="${article.sectionTitle}">Раздел</a>
</nav>
<h1 class="page-title" th:text="${article.title}">Статья</h1>
<div class="card card-body md-body" th:utext="${article.bodyHtml}"></div>
</div>
</body>
</html>

View File

@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title>Материалы — LMS</title></head>
<body>
<div id="content">
<div class="flex items-center justify-between gap-4 mb-6">
<h1 class="page-title">Учебные материалы</h1>
<div th:if="${currentUser != null and currentUser.admin}" class="flex gap-2">
<button class="btn btn-primary" type="button" onclick="openModal('modal-new-article')">+ Статья</button>
<button class="btn btn-secondary" type="button" onclick="openModal('modal-import')">Импорт zip</button>
<button class="btn btn-secondary" type="button" onclick="openModal('modal-manage')">Главы и разделы</button>
</div>
</div>
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
<div th:if="${tree == null || #lists.isEmpty(tree.chapters)}" class="card card-body text-muted">
Материалы ещё не добавлены.
</div>
<div th:each="chapter : ${tree.chapters}" class="card mb-4" th:id="|chapter-${chapter.id}|">
<div class="card-body">
<div class="flex items-center justify-between gap-4 mb-4">
<h2 class="section-title" th:text="${chapter.title}">Глава</h2>
<div th:if="${currentUser != null and currentUser.admin}" class="flex gap-2">
<button class="btn btn-secondary btn-sm" type="button"
th:onclick="openModal('modal-rename-chapter-'+${chapter.id})">Переименовать</button>
<form th:action="@{/admin/content/chapters/{id}/delete(id=${chapter.id})}" method="post"
onsubmit="return confirm('Удалить главу со всеми разделами и статьями?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
</form>
</div>
</div>
<div th:each="section : ${chapter.sections}" class="mt-4"
th:id="|section-${section.id}|">
<div class="flex items-center justify-between gap-4">
<h3 class="group-title">
<a th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}" th:text="${section.title}">Раздел</a>
</h3>
<div th:if="${currentUser != null and currentUser.admin}" class="flex gap-2">
<button class="btn btn-secondary btn-sm" type="button"
th:onclick="openModal('modal-rename-section-'+${section.id})">Переименовать</button>
<button class="btn btn-secondary btn-sm" type="button"
th:onclick="openModal('modal-move-'+${section.id})">Перенести статьи</button>
<form th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
onsubmit="return confirm('Удалить раздел со всеми статьями?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
</form>
</div>
</div>
<ul class="list-pad text-sm">
<li th:each="article : ${section.articles}" class="flex items-center gap-2">
<a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
th:text="${article.title}">Статья</a>
<th:block th:if="${currentUser != null and currentUser.admin}">
<button class="btn btn-secondary btn-sm" type="button"
th:onclick="openModal('modal-edit-article-'+${article.id})">Править</button>
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post"
onsubmit="return confirm('Удалить статью?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-danger btn-sm" type="submit">✕</button>
</form>
</th:block>
</li>
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
</ul>
</div>
<p th:if="${#lists.isEmpty(chapter.sections)}" class="text-sm text-muted">Разделов пока нет</p>
</div>
</div>
</div>
<dialog id="modal-import" class="modal">
<div class="modal-header">
<h2 class="modal-title">Импорт материалов (zip)</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-import')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/import}" method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<p class="text-sm text-muted">
Структура: <code class="code">chapters/01-глава/01-раздел/01-статья.md</code>, картинки в <code class="code">assets/</code>.
</p>
<div><label class="label" for="file">Архив</label>
<input class="input" id="file" name="file" type="file" accept=".zip" required></div>
<button class="btn btn-primary" type="submit">Импортировать</button>
</form>
</div>
</dialog>
<dialog id="modal-new-article" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новая статья</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-new-article')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/articles/create}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<div><label class="label" for="naChapter">Глава (можно создать новую)</label>
<div class="inline-form w-full">
<select class="input" id="naChapter" name="chapterId" th:if="${tree != null}">
<option th:each="chapter : ${tree.chapters}" th:value="${chapter.id}" th:text="${chapter.title}">—</option>
</select>
<span class="text-muted">или</span>
<input class="input" name="newChapterTitle" placeholder="Новая глава">
</div>
</div>
<div><label class="label" for="naSection">Раздел (можно создать новый)</label>
<input class="input" id="naSection" name="newSectionTitle" placeholder="Новый раздел"></div>
<div><label class="label" for="naTitle">Заголовок статьи</label>
<input class="input" id="naTitle" name="title" required></div>
<div><label class="label" for="naBody">Текст (markdown)</label>
<textarea class="input" id="naBody" name="bodyMd" rows="10"></textarea></div>
<button class="btn btn-primary" type="submit">Создать</button>
</form>
</div>
</dialog>
<th:block th:each="chapter : ${tree?.chapters ?: {}}">
<dialog th:id="|modal-rename-chapter-${chapter.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Переименовать главу</h2>
<button class="modal-close" type="button" th:onclick="closeModal('modal-rename-chapter-'+[[${chapter.id}]])">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/chapters/{id}/rename(id=${chapter.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<div><label class="label">Название</label>
<input class="input" name="title" th:value="${chapter.title}" required></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
<th:block th:each="section : ${chapter.sections}">
<dialog th:id="|modal-rename-section-${section.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Переименовать раздел</h2>
<button class="modal-close" type="button" th:onclick="closeModal('modal-rename-section-'+[[${section.id}]])">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<div><label class="label">Название</label>
<input class="input" name="title" th:value="${section.title}" required></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
<dialog th:id="|modal-move-${section.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Перенести статьи из раздела</h2>
<button class="modal-close" type="button" th:onclick="closeModal('modal-move-'+[[${section.id}]])">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<p class="text-sm text-muted">Укажите раздел, в который перенести статьи, затем подтвердите удаление раздела.</p>
<div><label class="label">Перенести в раздел</label>
<select class="input" name="moveTo" th:if="${tree != null}">
<th:block th:each="ch2 : ${tree.chapters}">
<optgroup th:label="${ch2.title}">
<option th:each="s2 : ${ch2.sections}"
th:if="${s2.id != section.id}"
th:value="${s2.id}" th:text="${ch2.title} / ${s2.title}">—</option>
</optgroup>
</th:block>
</select></div>
<button class="btn btn-primary" type="submit">Перенести и удалить раздел</button>
</form>
</div>
</dialog>
</th:block>
</th:block>
<th:block th:each="a : ${articles}">
<dialog th:id="|modal-edit-article-${a.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Правка статьи</h2>
<button class="modal-close" type="button" th:onclick="closeModal('modal-edit-article-'+[[${a.id}]])">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/articles/{id}/save(id=${a.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<div><label class="label">Заголовок</label>
<input class="input" name="title" th:value="${a.title}" required></div>
<div><label class="label">Текст (markdown)</label>
<textarea class="input" name="bodyMd" rows="14" th:text="${a.bodyMd}"></textarea></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
</th:block>
</body>
</html>

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title th:text="${section.title} + ' — LMS'">Раздел</title></head>
<body>
<div id="content">
<a th:href="@{/learn}" class="nav-link">← Все материалы</a>
<h1 class="page-title mt-4" th:text="${section.title}">Раздел</h1>
<p class="text-sm text-muted mb-6">Глава: <span th:text="${chapter.title}">—</span></p>
<div class="card card-flush">
<table>
<thead><tr><th>Статья</th></tr></thead>
<tbody>
<tr th:each="article : ${section.articles}">
<td><a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
th:text="${article.title}">—</a></td>
</tr>
<tr th:if="${#lists.isEmpty(section.articles)}"><td class="empty">Статей пока нет.</td></tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -13,6 +13,7 @@
<div><label class="label" for="login">Логин</label><input class="input" id="login" name="login" th:value="${login}" autofocus required></div> <div><label class="label" for="login">Логин</label><input class="input" id="login" name="login" th:value="${login}" autofocus required></div>
<div><label class="label" for="password">Пароль</label><input class="input" id="password" name="password" type="password" required></div> <div><label class="label" for="password">Пароль</label><input class="input" id="password" name="password" type="password" required></div>
<button class="btn btn-primary" type="submit">Войти</button> <button class="btn btn-primary" type="submit">Войти</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>
</div> </div>
</div> </div>

View File

@@ -12,6 +12,7 @@
<div><label class="label" for="password">Пароль</label><input class="input" id="password" name="password" type="password" required><p class="hint">Минимум 8 символов.</p></div> <div><label class="label" for="password">Пароль</label><input class="input" id="password" name="password" type="password" required><p class="hint">Минимум 8 символов.</p></div>
<div><label class="label" for="password2">Повторите пароль</label><input class="input" id="password2" name="password2" type="password" required></div> <div><label class="label" for="password2">Повторите пароль</label><input class="input" id="password2" name="password2" type="password" required></div>
<button class="btn btn-primary" type="submit">Создать и войти</button> <button class="btn btn-primary" type="submit">Создать и войти</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>
</div> </div>
</div> </div>