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:
1
.idea/vcs.xml
generated
1
.idea/vcs.xml
generated
@@ -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>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package ru.ysu.lms.academic.content
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import ru.ysu.lms.common.BadRequestException
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") root: String) {
|
||||||
|
private val rootDir: Path = Path.of(root)
|
||||||
|
|
||||||
|
init {
|
||||||
|
Files.createDirectories(rootDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class StoredAsset(val publicName: String, val storedPath: Path, val mime: String)
|
||||||
|
|
||||||
|
fun store(originalName: String, bytes: ByteArray): StoredAsset {
|
||||||
|
if (bytes.size > MAX_ASSET_BYTES) {
|
||||||
|
throw BadRequestException("Файл $originalName слишком большой (> ${MAX_ASSET_BYTES / 1024 / 1024} МБ)")
|
||||||
|
}
|
||||||
|
val ext = originalName.substringAfterLast('.', "").lowercase()
|
||||||
|
val allowed = setOf("png", "jpg", "jpeg", "gif", "svg", "webp")
|
||||||
|
if (ext !in allowed) {
|
||||||
|
throw BadRequestException("Недопустимый тип файла: $originalName (разрешены ${allowed.joinToString(", ")})")
|
||||||
|
}
|
||||||
|
val publicName = "${UUID.randomUUID()}.$ext"
|
||||||
|
val target = rootDir.resolve(publicName).normalize()
|
||||||
|
if (!target.startsWith(rootDir)) {
|
||||||
|
throw BadRequestException("Некорректный путь файла")
|
||||||
|
}
|
||||||
|
Files.write(target, bytes)
|
||||||
|
return StoredAsset(publicName, target, mimeFor(ext))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun read(publicName: String): Pair<Path, String>? {
|
||||||
|
val clean = publicName.substringAfterLast('/')
|
||||||
|
val target = rootDir.resolve(clean).normalize()
|
||||||
|
if (!target.startsWith(rootDir) || target.parent != rootDir) return null
|
||||||
|
if (!Files.isRegularFile(target)) return null
|
||||||
|
val ext = clean.substringAfterLast('.', "").lowercase()
|
||||||
|
return target to mimeFor(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mimeFor(ext: String): String = when (ext) {
|
||||||
|
"png" -> "image/png"
|
||||||
|
"jpg", "jpeg" -> "image/jpeg"
|
||||||
|
"gif" -> "image/gif"
|
||||||
|
"svg" -> "image/svg+xml"
|
||||||
|
"webp" -> "image/webp"
|
||||||
|
else -> "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MAX_ASSET_BYTES = 10 * 1024 * 1024
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
package ru.ysu.lms.academic.content
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import ru.ysu.lms.academic.domain.ContentArticle
|
||||||
|
import ru.ysu.lms.academic.domain.ContentChapter
|
||||||
|
import ru.ysu.lms.academic.domain.ContentSection
|
||||||
|
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
||||||
|
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
||||||
|
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||||
|
import ru.ysu.lms.common.BadRequestException
|
||||||
|
import ru.ysu.lms.common.NotFoundException
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
|
||||||
|
data class ArticleTreeNode(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val slug: String,
|
||||||
|
val position: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SectionTreeNode(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val slug: String,
|
||||||
|
val position: Int,
|
||||||
|
val articles: List<ArticleTreeNode>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChapterTreeNode(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val slug: String,
|
||||||
|
val position: Int,
|
||||||
|
val sections: List<SectionTreeNode>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ContentTree(val chapters: List<ChapterTreeNode>)
|
||||||
|
|
||||||
|
data class ContentArticleView(
|
||||||
|
val id: String,
|
||||||
|
val chapterSlug: String,
|
||||||
|
val chapterTitle: String,
|
||||||
|
val sectionSlug: String,
|
||||||
|
val sectionTitle: String,
|
||||||
|
val slug: String,
|
||||||
|
val title: String,
|
||||||
|
val bodyMd: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ContentImportResult(
|
||||||
|
val chapters: Int,
|
||||||
|
val sections: Int,
|
||||||
|
val articles: Int,
|
||||||
|
val assets: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ParsedArticle(
|
||||||
|
val path: String,
|
||||||
|
val chapterPos: Int,
|
||||||
|
val chapterDir: String,
|
||||||
|
val sectionPos: Int,
|
||||||
|
val sectionDir: String,
|
||||||
|
val articlePos: Int,
|
||||||
|
val fileTitle: String,
|
||||||
|
val fmSlug: String?,
|
||||||
|
val body: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class ContentService(
|
||||||
|
private val chapters: ContentChapterRepository,
|
||||||
|
private val sections: ContentSectionRepository,
|
||||||
|
private val articles: ContentArticleRepository,
|
||||||
|
private val assets: AssetStorage,
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun tree(): ContentTree {
|
||||||
|
val chapterList = chapters.findAllByOrderByPositionAscIdAsc()
|
||||||
|
val sectionList = sections.findAllByOrderByPositionAscIdAsc().groupBy { it.chapterId }
|
||||||
|
val articleList = articles.findAllByOrderByPositionAscIdAsc().groupBy { it.sectionId }
|
||||||
|
return ContentTree(
|
||||||
|
chapterList.map { ch ->
|
||||||
|
ChapterTreeNode(
|
||||||
|
id = ch.id.toString(),
|
||||||
|
title = ch.title,
|
||||||
|
slug = ch.slug,
|
||||||
|
position = ch.position,
|
||||||
|
sections = (sectionList[ch.id] ?: emptyList()).map { sec ->
|
||||||
|
SectionTreeNode(
|
||||||
|
id = sec.id.toString(),
|
||||||
|
title = sec.title,
|
||||||
|
slug = sec.slug,
|
||||||
|
position = sec.position,
|
||||||
|
articles = (articleList[sec.id] ?: emptyList()).map { a ->
|
||||||
|
ArticleTreeNode(a.id.toString(), a.title, a.slug, a.position)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun findArticle(chapterSlug: String, sectionSlug: String, articleSlug: String): ContentArticleView {
|
||||||
|
val ch = chapters.findBySlug(chapterSlug) ?: throw NotFoundException("Глава не найдена")
|
||||||
|
val sec = sections.findByChapterIdAndSlug(ch.id, sectionSlug) ?: throw NotFoundException("Раздел не найден")
|
||||||
|
val a = articles.findBySectionIdAndSlug(sec.id, articleSlug) ?: throw NotFoundException("Статья не найдена")
|
||||||
|
return ContentArticleView(
|
||||||
|
id = a.id.toString(),
|
||||||
|
chapterSlug = ch.slug,
|
||||||
|
chapterTitle = ch.title,
|
||||||
|
sectionSlug = sec.slug,
|
||||||
|
sectionTitle = sec.title,
|
||||||
|
slug = a.slug,
|
||||||
|
title = a.title,
|
||||||
|
bodyMd = a.bodyMd,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun listAll(): List<ContentArticleView> {
|
||||||
|
val chById = chapters.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
|
||||||
|
val secById = sections.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
|
||||||
|
return articles.findAllByOrderByPositionAscIdAsc().mapNotNull { a ->
|
||||||
|
val sec = secById[a.sectionId] ?: return@mapNotNull null
|
||||||
|
val ch = chById[sec.chapterId] ?: return@mapNotNull null
|
||||||
|
ContentArticleView(
|
||||||
|
id = a.id.toString(),
|
||||||
|
chapterSlug = ch.slug,
|
||||||
|
chapterTitle = ch.title,
|
||||||
|
sectionSlug = sec.slug,
|
||||||
|
sectionTitle = sec.title,
|
||||||
|
slug = a.slug,
|
||||||
|
title = a.title,
|
||||||
|
bodyMd = a.bodyMd,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun updateArticle(articleId: UUID, title: String, bodyMd: String) {
|
||||||
|
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
|
||||||
|
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
|
||||||
|
a.title = title.trim()
|
||||||
|
a.bodyMd = bodyMd
|
||||||
|
articles.save(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun deleteArticle(articleId: UUID) {
|
||||||
|
if (!articles.existsById(articleId)) throw NotFoundException("Статья не найдена")
|
||||||
|
articles.deleteById(articleId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun createChapter(title: String, slug: String?): ChapterTreeNode {
|
||||||
|
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
|
||||||
|
val cleanSlug = slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
|
||||||
|
?: uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
|
||||||
|
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
|
||||||
|
val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1))
|
||||||
|
return ChapterTreeNode(ch.id.toString(), ch.title, ch.slug, ch.position, emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun renameChapter(chapterId: UUID, title: String) {
|
||||||
|
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||||
|
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
|
||||||
|
ch.title = title.trim()
|
||||||
|
chapters.save(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun deleteChapter(chapterId: UUID) {
|
||||||
|
if (!chapters.existsById(chapterId)) throw NotFoundException("Глава не найдена")
|
||||||
|
chapters.deleteById(chapterId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun createSection(chapterId: UUID, title: String, slug: String?): SectionTreeNode {
|
||||||
|
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||||
|
if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым")
|
||||||
|
val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id)
|
||||||
|
val cleanSlug = slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
|
||||||
|
?: uniqueSlug(title, siblings.map { it.slug }.toSet())
|
||||||
|
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
|
||||||
|
val sec = sections.save(
|
||||||
|
ContentSection(chapterId = ch.id, title = title.trim(), slug = cleanSlug, position = maxPos + 1),
|
||||||
|
)
|
||||||
|
return SectionTreeNode(sec.id.toString(), sec.title, sec.slug, sec.position, emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun renameSection(sectionId: UUID, title: String) {
|
||||||
|
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||||
|
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
|
||||||
|
sec.title = title.trim()
|
||||||
|
sections.save(sec)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun deleteSection(sectionId: UUID) {
|
||||||
|
if (!sections.existsById(sectionId)) throw NotFoundException("Раздел не найден")
|
||||||
|
sections.deleteById(sectionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Переносит все статьи раздела в другой и удаляет раздел. */
|
||||||
|
@Transactional
|
||||||
|
fun moveArticlesAndDeleteSection(sectionId: UUID, targetSectionId: UUID) {
|
||||||
|
val src = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||||
|
val dst = sections.findById(targetSectionId).orElseThrow { NotFoundException("Раздел назначения не найден") }
|
||||||
|
if (src.id == dst.id) throw BadRequestException("Раздел назначения совпадает с исходным")
|
||||||
|
val moving = articles.findBySectionIdOrderByPositionAscIdAsc(src.id)
|
||||||
|
var pos = articles.findBySectionIdOrderByPositionAscIdAsc(dst.id).maxOfOrNull { it.position } ?: 0
|
||||||
|
for (a in moving) {
|
||||||
|
pos += 1
|
||||||
|
a.sectionId = dst.id
|
||||||
|
a.position = pos
|
||||||
|
articles.save(a)
|
||||||
|
}
|
||||||
|
sections.delete(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Создаёт статью; если раздела нет — создаёт главу/раздел по названиям. */
|
||||||
|
@Transactional
|
||||||
|
fun createArticlePlaced(title: String, bodyMd: String, chapterId: UUID?, newChapterTitle: String?,
|
||||||
|
sectionId: UUID?, newSectionTitle: String?): ContentArticleView {
|
||||||
|
val targetSection: ContentSection = when {
|
||||||
|
sectionId != null -> sections.findById(sectionId)
|
||||||
|
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||||
|
!newSectionTitle.isNullOrBlank() -> {
|
||||||
|
val chapter: ContentChapter = when {
|
||||||
|
chapterId != null -> chapters.findById(chapterId)
|
||||||
|
.orElseThrow { NotFoundException("Глава не найдена") }
|
||||||
|
!newChapterTitle.isNullOrBlank() -> {
|
||||||
|
val node = createChapter(newChapterTitle, null)
|
||||||
|
chapters.findById(UUID.fromString(node.id))
|
||||||
|
.orElseThrow { NotFoundException("Глава не найдена") }
|
||||||
|
}
|
||||||
|
else -> throw BadRequestException("Укажите главу")
|
||||||
|
}
|
||||||
|
val node = createSection(chapter.id, newSectionTitle, null)
|
||||||
|
sections.findById(UUID.fromString(node.id))
|
||||||
|
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||||
|
}
|
||||||
|
else -> throw BadRequestException("Укажите раздел")
|
||||||
|
}
|
||||||
|
return createArticle(targetSection.id, title, bodyMd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun createArticle(sectionId: UUID, title: String, bodyMd: String): ContentArticleView {
|
||||||
|
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||||
|
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||||
|
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
|
||||||
|
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
|
||||||
|
val slug = uniqueSlug(title, siblings.map { it.slug }.toSet())
|
||||||
|
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
|
||||||
|
val a = articles.save(
|
||||||
|
ContentArticle(sectionId = sec.id, title = title.trim(), slug = slug, bodyMd = bodyMd, position = maxPos + 1),
|
||||||
|
)
|
||||||
|
return ContentArticleView(
|
||||||
|
id = a.id.toString(), chapterSlug = ch.slug, chapterTitle = ch.title,
|
||||||
|
sectionSlug = sec.slug, sectionTitle = sec.title,
|
||||||
|
slug = a.slug, title = a.title, bodyMd = a.bodyMd,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun moveArticle(articleId: UUID, newSectionId: UUID): ContentArticleView {
|
||||||
|
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
|
||||||
|
val sec = sections.findById(newSectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||||
|
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||||
|
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
|
||||||
|
a.sectionId = sec.id
|
||||||
|
a.position = (siblings.maxOfOrNull { it.position } ?: 0) + 1
|
||||||
|
articles.save(a)
|
||||||
|
return ContentArticleView(
|
||||||
|
id = a.id.toString(), chapterSlug = ch.slug, chapterTitle = ch.title,
|
||||||
|
sectionSlug = sec.slug, sectionTitle = sec.title,
|
||||||
|
slug = a.slug, title = a.title, bodyMd = a.bodyMd,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun uniqueSlug(title: String, taken: Set<String>): String {
|
||||||
|
val base = slugify(title) ?: "item"
|
||||||
|
if (base !in taken) return base
|
||||||
|
var i = 2
|
||||||
|
while ("$base-$i" in taken) i += 1
|
||||||
|
return "$base-$i"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Импорт zip со структурой каталогов:
|
||||||
|
* chapters/01-глава/01-раздел/01-статья.md (front-matter: title, slug),
|
||||||
|
* картинки в assets/.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
fun importZip(filename: String, bytes: ByteArray): ContentImportResult {
|
||||||
|
if (!filename.lowercase().endsWith(".zip")) {
|
||||||
|
throw BadRequestException("Ожидается .zip архив")
|
||||||
|
}
|
||||||
|
val files = readZip(bytes)
|
||||||
|
if (files.isEmpty()) throw BadRequestException("Архив пуст")
|
||||||
|
|
||||||
|
val assetFiles = files.filterKeys { it.contains("/assets/") || it.startsWith("assets/") }
|
||||||
|
val contentFiles = files.filterKeys { k ->
|
||||||
|
(k.contains("/chapters/") || k.startsWith("chapters/")) && k.endsWith(".md")
|
||||||
|
}
|
||||||
|
if (contentFiles.isEmpty()) throw BadRequestException("В архиве нет каталога chapters/ с .md файлами")
|
||||||
|
|
||||||
|
val linkRewrite = mutableMapOf<String, String>()
|
||||||
|
for ((path, data) in assetFiles) {
|
||||||
|
val name = path.substringAfterLast('/')
|
||||||
|
if (name.isBlank()) continue
|
||||||
|
val stored = assets.store(name, data)
|
||||||
|
linkRewrite["assets/$name"] = "/api/v1/content/assets/${stored.publicName}"
|
||||||
|
}
|
||||||
|
|
||||||
|
val parsed = contentFiles.map { (path, data) ->
|
||||||
|
val clean = path.removePrefix("/").removePrefix("chapters/").removeSuffix(".md")
|
||||||
|
val parts = clean.split('/')
|
||||||
|
if (parts.size < 3) {
|
||||||
|
throw BadRequestException("Неверная структура: $path (ожидается chapters/NN-глава/NN-раздел/NN-статья.md)")
|
||||||
|
}
|
||||||
|
val chapterDir = parts[0]
|
||||||
|
val sectionDir = parts[1]
|
||||||
|
val fileName = parts.drop(2).joinToString("-")
|
||||||
|
val parsedContent = splitFrontMatter(String(data, Charsets.UTF_8))
|
||||||
|
ParsedArticle(
|
||||||
|
path = path,
|
||||||
|
chapterPos = positionOf(chapterDir),
|
||||||
|
chapterDir = titleFromDir(chapterDir),
|
||||||
|
sectionPos = positionOf(sectionDir),
|
||||||
|
sectionDir = titleFromDir(sectionDir),
|
||||||
|
articlePos = positionOf(fileName),
|
||||||
|
fileTitle = parsedContent.first["title"] ?: titleFromDir(fileName),
|
||||||
|
fmSlug = parsedContent.first["slug"],
|
||||||
|
body = parsedContent.second,
|
||||||
|
)
|
||||||
|
}.sortedWith(
|
||||||
|
compareBy(
|
||||||
|
{ it.chapterPos },
|
||||||
|
{ it.chapterDir },
|
||||||
|
{ it.sectionPos },
|
||||||
|
{ it.sectionDir },
|
||||||
|
{ it.articlePos },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
var chCount = 0
|
||||||
|
var secCount = 0
|
||||||
|
var artCount = 0
|
||||||
|
var currentChapter: ContentChapter? = null
|
||||||
|
var currentSection: ContentSection? = null
|
||||||
|
|
||||||
|
for (p in parsed) {
|
||||||
|
if (currentChapter?.title != p.chapterDir) {
|
||||||
|
val slug = chapterSlug(p) ?: "chapter-${chCount + 1}"
|
||||||
|
currentChapter = chapters.findBySlug(slug) ?: chapters.save(
|
||||||
|
ContentChapter(title = p.chapterDir, slug = slug, position = p.chapterPos),
|
||||||
|
)
|
||||||
|
chCount += 1
|
||||||
|
currentSection = null
|
||||||
|
}
|
||||||
|
val chapter = currentChapter ?: throw BadRequestException("Не удалось определить главу")
|
||||||
|
if (currentSection?.title != p.sectionDir) {
|
||||||
|
val slug = sectionSlug(p) ?: "section-${secCount + 1}"
|
||||||
|
currentSection = sections.findByChapterIdAndSlug(chapter.id, slug) ?: sections.save(
|
||||||
|
ContentSection(chapterId = chapter.id, title = p.sectionDir, slug = slug, position = p.sectionPos),
|
||||||
|
)
|
||||||
|
secCount += 1
|
||||||
|
}
|
||||||
|
val section = currentSection ?: throw BadRequestException("Не удалось определить раздел")
|
||||||
|
|
||||||
|
var body = p.body
|
||||||
|
for ((from, to) in linkRewrite) {
|
||||||
|
body = body.replace(from, to)
|
||||||
|
}
|
||||||
|
val slug = p.fmSlug?.takeIf { it.isNotBlank() }
|
||||||
|
?: articleSlug(p)
|
||||||
|
?: "article-${artCount + 1}"
|
||||||
|
val existing = articles.findBySectionIdAndSlug(section.id, slug)
|
||||||
|
val article = (existing ?: ContentArticle(sectionId = section.id, slug = slug)).apply {
|
||||||
|
title = p.fileTitle
|
||||||
|
bodyMd = body
|
||||||
|
position = p.articlePos
|
||||||
|
}
|
||||||
|
articles.save(article)
|
||||||
|
artCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return ContentImportResult(chapters = chCount, sections = secCount, articles = artCount, assets = linkRewrite.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chapterSlug(p: ParsedArticle): String? =
|
||||||
|
slugify("%02d".format(p.chapterPos) + "-" + p.chapterDir)
|
||||||
|
|
||||||
|
private fun sectionSlug(p: ParsedArticle): String? =
|
||||||
|
slugify("%02d".format(p.sectionPos) + "-" + p.sectionDir)
|
||||||
|
|
||||||
|
private fun articleSlug(p: ParsedArticle): String? =
|
||||||
|
slugify("%02d".format(p.articlePos) + "-" + p.fileTitle)
|
||||||
|
|
||||||
|
private fun readZip(bytes: ByteArray): Map<String, ByteArray> {
|
||||||
|
val result = mutableMapOf<String, ByteArray>()
|
||||||
|
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
|
||||||
|
var entry = zis.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
if (!entry.isDirectory) {
|
||||||
|
result[entry.name.removePrefix("/")] = zis.readAllBytes()
|
||||||
|
}
|
||||||
|
entry = zis.nextEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun splitFrontMatter(content: String): Pair<Map<String, String>, String> {
|
||||||
|
if (!content.startsWith("---")) return emptyMap<String, String>() to content
|
||||||
|
val lines = content.lines()
|
||||||
|
val end = lines.withIndex()
|
||||||
|
.firstOrNull { it.index != 0 && it.value.trim() == "---" }
|
||||||
|
?.index ?: return emptyMap<String, String>() to content
|
||||||
|
val fm = mutableMapOf<String, String>()
|
||||||
|
for (line in lines.subList(1, end)) {
|
||||||
|
val parts = line.split(":", limit = 2)
|
||||||
|
if (parts.size == 2) {
|
||||||
|
val v = parts[1].trim()
|
||||||
|
val unquoted = if (v.length >= 2 && v.first() == '"' && v.last() == '"') {
|
||||||
|
v.substring(1, v.length - 1)
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
fm[parts[0].trim()] = unquoted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fm to lines.drop(end + 1).joinToString("\n").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun positionOf(name: String): Int {
|
||||||
|
val m = Regex("^(\\d{1,3})[-_]").find(name.substringAfterLast('/'))
|
||||||
|
return m?.groupValues?.get(1)?.toIntOrNull() ?: 999
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun titleFromDir(dir: String): String {
|
||||||
|
val raw = dir.substringAfterLast('/')
|
||||||
|
.replace(Regex("^\\d{1,3}[-_]"), "")
|
||||||
|
.replace('_', ' ')
|
||||||
|
return raw.ifBlank { dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun slugify(value: String): String? {
|
||||||
|
val translit = TRANSLIT.entries.fold(value.lowercase()) { acc, (from, to) -> acc.replace(from, to) }
|
||||||
|
val slug = translit
|
||||||
|
.replace(Regex("\\s+"), "-")
|
||||||
|
.replace(Regex("[^a-z0-9-]"), "")
|
||||||
|
.replace(Regex("-+"), "-")
|
||||||
|
.trim('-')
|
||||||
|
return slug.ifBlank { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val TRANSLIT = mapOf(
|
||||||
|
"а" to "a", "б" to "b", "в" to "v", "г" to "g", "д" to "d", "е" to "e", "ё" to "e",
|
||||||
|
"ж" to "zh", "з" to "z", "и" to "i", "й" to "y", "к" to "k", "л" to "l", "м" to "m",
|
||||||
|
"н" to "n", "о" to "o", "п" to "p", "р" to "r", "с" to "s", "т" to "t", "у" to "u",
|
||||||
|
"ф" to "f", "х" to "h", "ц" to "c", "ч" to "ch", "ш" to "sh", "щ" to "sch", "ъ" to "",
|
||||||
|
"ы" to "y", "ь" to "", "э" to "e", "ю" to "yu", "я" to "ya",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package ru.ysu.lms.academic.controller
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import ru.ysu.lms.academic.content.ContentImportResult
|
||||||
|
import ru.ysu.lms.academic.content.ContentService
|
||||||
|
import ru.ysu.lms.common.BadRequestException
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/content")
|
||||||
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
|
class ContentAdminController(private val contentService: ContentService) {
|
||||||
|
|
||||||
|
data class ArticleUpdatePayload(val title: String, val bodyMd: String)
|
||||||
|
data class ArticleCreatePayload(
|
||||||
|
val chapterId: String? = null,
|
||||||
|
val sectionId: String? = null,
|
||||||
|
val newChapterTitle: String? = null,
|
||||||
|
val newSectionTitle: String? = null,
|
||||||
|
val title: String,
|
||||||
|
val bodyMd: String = "",
|
||||||
|
)
|
||||||
|
data class ArticleMovePayload(val sectionId: String)
|
||||||
|
data class TitlePayload(val title: String)
|
||||||
|
data class ChapterCreatePayload(val title: String, val slug: String? = null)
|
||||||
|
data class SectionCreatePayload(val title: String, val slug: String? = null)
|
||||||
|
|
||||||
|
@PostMapping("/import")
|
||||||
|
fun import(@RequestParam("file") file: MultipartFile): ContentImportResult {
|
||||||
|
if (file.isEmpty) throw BadRequestException("Выберите файл")
|
||||||
|
return contentService.importZip(file.originalFilename ?: "content.zip", file.bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/articles")
|
||||||
|
fun list() = contentService.listAll()
|
||||||
|
|
||||||
|
@PostMapping("/chapters")
|
||||||
|
fun createChapter(@RequestBody body: ChapterCreatePayload) = contentService.createChapter(body.title, body.slug)
|
||||||
|
|
||||||
|
@PutMapping("/chapters/{id}")
|
||||||
|
fun renameChapter(@PathVariable id: UUID, @RequestBody body: TitlePayload) =
|
||||||
|
contentService.renameChapter(id, body.title)
|
||||||
|
|
||||||
|
@DeleteMapping("/chapters/{id}")
|
||||||
|
fun deleteChapter(@PathVariable id: UUID): ResponseEntity<Void> {
|
||||||
|
contentService.deleteChapter(id)
|
||||||
|
return ResponseEntity.noContent().build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/sections")
|
||||||
|
fun createSection(@RequestParam chapterId: UUID, @RequestBody body: SectionCreatePayload) =
|
||||||
|
contentService.createSection(chapterId, body.title, body.slug)
|
||||||
|
|
||||||
|
@PutMapping("/sections/{id}")
|
||||||
|
fun renameSection(@PathVariable id: UUID, @RequestBody body: TitlePayload) =
|
||||||
|
contentService.renameSection(id, body.title)
|
||||||
|
|
||||||
|
@DeleteMapping("/sections/{id}")
|
||||||
|
fun deleteSection(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
@RequestParam(required = false) moveTo: UUID?,
|
||||||
|
): ResponseEntity<Void> {
|
||||||
|
if (moveTo != null) {
|
||||||
|
contentService.moveArticlesAndDeleteSection(id, moveTo)
|
||||||
|
} else {
|
||||||
|
contentService.deleteSection(id)
|
||||||
|
}
|
||||||
|
return ResponseEntity.noContent().build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/articles")
|
||||||
|
fun createArticle(@RequestBody body: ArticleCreatePayload) = contentService.createArticlePlaced(
|
||||||
|
title = body.title,
|
||||||
|
bodyMd = body.bodyMd,
|
||||||
|
chapterId = body.chapterId?.let { UUID.fromString(it) },
|
||||||
|
newChapterTitle = body.newChapterTitle,
|
||||||
|
sectionId = body.sectionId?.let { UUID.fromString(it) },
|
||||||
|
newSectionTitle = body.newSectionTitle,
|
||||||
|
)
|
||||||
|
|
||||||
|
@PutMapping("/articles/{id}")
|
||||||
|
fun update(@PathVariable id: UUID, @RequestBody body: ArticleUpdatePayload) {
|
||||||
|
contentService.updateArticle(id, body.title, body.bodyMd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/articles/{id}/move")
|
||||||
|
fun move(@PathVariable id: UUID, @RequestBody body: ArticleMovePayload) =
|
||||||
|
contentService.moveArticle(id, UUID.fromString(body.sectionId))
|
||||||
|
|
||||||
|
@DeleteMapping("/articles/{id}")
|
||||||
|
fun delete(@PathVariable id: UUID): ResponseEntity<Void> {
|
||||||
|
contentService.deleteArticle(id)
|
||||||
|
return ResponseEntity.noContent().build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package ru.ysu.lms.academic.controller
|
||||||
|
|
||||||
|
import org.springframework.core.io.FileSystemResource
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import ru.ysu.lms.academic.content.AssetStorage
|
||||||
|
import ru.ysu.lms.academic.content.ContentService
|
||||||
|
import java.nio.file.Files
|
||||||
|
|
||||||
|
/** Публичное чтение учебных материалов — доступно без аутентификации. */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/content")
|
||||||
|
class ContentPublicController(
|
||||||
|
private val contentService: ContentService,
|
||||||
|
private val assetStorage: AssetStorage,
|
||||||
|
) {
|
||||||
|
|
||||||
|
@GetMapping("/tree")
|
||||||
|
fun tree() = contentService.tree()
|
||||||
|
|
||||||
|
@GetMapping("/{chapter}/{section}/{article}")
|
||||||
|
fun article(
|
||||||
|
@PathVariable chapter: String,
|
||||||
|
@PathVariable section: String,
|
||||||
|
@PathVariable article: String,
|
||||||
|
): ContentArticleViewDto {
|
||||||
|
val a = contentService.findArticle(chapter, section, article)
|
||||||
|
return ContentArticleViewDto(
|
||||||
|
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle,
|
||||||
|
sectionSlug = a.sectionSlug, sectionTitle = a.sectionTitle,
|
||||||
|
slug = a.slug, title = a.title, bodyMd = a.bodyMd,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ContentArticleViewDto(
|
||||||
|
val id: String,
|
||||||
|
val chapterSlug: String,
|
||||||
|
val chapterTitle: String,
|
||||||
|
val sectionSlug: String,
|
||||||
|
val sectionTitle: String,
|
||||||
|
val slug: String,
|
||||||
|
val title: String,
|
||||||
|
val bodyMd: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@GetMapping("/assets/{name}")
|
||||||
|
fun asset(@PathVariable name: String): ResponseEntity<FileSystemResource> {
|
||||||
|
val (path, mime) = assetStorage.read(name)
|
||||||
|
?: return ResponseEntity.notFound().build()
|
||||||
|
val media = runCatching { MediaType.parseMediaType(mime) }.getOrElse { MediaType.APPLICATION_OCTET_STREAM }
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(media)
|
||||||
|
.cacheControl(org.springframework.http.CacheControl.maxAge(java.time.Duration.ofDays(7)))
|
||||||
|
.body(FileSystemResource(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,4 +54,28 @@ class LabController(private val labService: LabService) {
|
|||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@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)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package ru.ysu.lms.academic.domain
|
||||||
|
|
||||||
|
import jakarta.persistence.Column
|
||||||
|
import jakarta.persistence.Entity
|
||||||
|
import jakarta.persistence.Id
|
||||||
|
import jakarta.persistence.Table
|
||||||
|
import ru.ysu.lms.common.Ids
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "chapters")
|
||||||
|
class ContentChapter(
|
||||||
|
@Id var id: UUID = Ids.v7(),
|
||||||
|
@Column(nullable = false, length = 200) var title: String = "",
|
||||||
|
@Column(nullable = false, unique = true, length = 200) var slug: String = "",
|
||||||
|
@Column(nullable = false) var position: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "sections")
|
||||||
|
class ContentSection(
|
||||||
|
@Id var id: UUID = Ids.v7(),
|
||||||
|
@Column(nullable = false) var chapterId: UUID = UUID(0, 0),
|
||||||
|
@Column(nullable = false, length = 200) var title: String = "",
|
||||||
|
@Column(nullable = false, length = 200) var slug: String = "",
|
||||||
|
@Column(nullable = false) var position: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "articles")
|
||||||
|
class ContentArticle(
|
||||||
|
@Id var id: UUID = Ids.v7(),
|
||||||
|
@Column(nullable = false) var sectionId: UUID = UUID(0, 0),
|
||||||
|
@Column(nullable = false, length = 200) var title: String = "",
|
||||||
|
@Column(nullable = false, length = 200) var slug: String = "",
|
||||||
|
@Column(nullable = false) var bodyMd: String = "",
|
||||||
|
@Column(nullable = false) var position: Int = 0,
|
||||||
|
)
|
||||||
@@ -16,7 +16,7 @@ data class LabLevelMeta(
|
|||||||
val code: String,
|
val 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? = null,
|
||||||
|
val statementMd: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LevelSaveRequest(
|
||||||
|
val code: String,
|
||||||
|
val title: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val position: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LabCreateRequest(
|
||||||
|
val number: Int? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val semesterTitle: String? = null,
|
||||||
|
val semesterYear: Int? = null,
|
||||||
|
val deadlineAt: String? = null,
|
||||||
|
val defenseAt: String? = null,
|
||||||
|
val pointsMax: BigDecimal? = null,
|
||||||
|
val status: String? = null,
|
||||||
|
val descriptionMd: String? = null,
|
||||||
|
val levels: List<LevelSaveRequest> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LevelRenameRequest(
|
||||||
val title: String,
|
val title: String,
|
||||||
val statementMd: String,
|
val description: String? = null,
|
||||||
)
|
)
|
||||||
@@ -4,6 +4,7 @@ import org.springframework.stereotype.Service
|
|||||||
import org.springframework.transaction.annotation.Transactional
|
import 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) }
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package ru.ysu.lms.academic.repo
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import ru.ysu.lms.academic.domain.ContentArticle
|
||||||
|
import ru.ysu.lms.academic.domain.ContentChapter
|
||||||
|
import ru.ysu.lms.academic.domain.ContentSection
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface ContentChapterRepository : JpaRepository<ContentChapter, UUID> {
|
||||||
|
fun findAllByOrderByPositionAscIdAsc(): List<ContentChapter>
|
||||||
|
fun findBySlug(slug: String): ContentChapter?
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContentSectionRepository : JpaRepository<ContentSection, UUID> {
|
||||||
|
fun findAllByOrderByPositionAscIdAsc(): List<ContentSection>
|
||||||
|
fun findByChapterIdOrderByPositionAscIdAsc(chapterId: UUID): List<ContentSection>
|
||||||
|
fun findByChapterIdAndSlug(chapterId: UUID, slug: String): ContentSection?
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContentArticleRepository : JpaRepository<ContentArticle, UUID> {
|
||||||
|
fun findBySectionIdOrderByPositionAscIdAsc(sectionId: UUID): List<ContentArticle>
|
||||||
|
fun findBySectionIdAndSlug(sectionId: UUID, slug: String): ContentArticle?
|
||||||
|
fun findAllByOrderByPositionAscIdAsc(): List<ContentArticle>
|
||||||
|
}
|
||||||
@@ -6,4 +6,5 @@ import java.util.UUID
|
|||||||
|
|
||||||
interface LabRepository : JpaRepository<Lab, UUID> {
|
interface LabRepository : JpaRepository<Lab, UUID> {
|
||||||
fun findAllByOrderByNumberAsc(): List<Lab>
|
fun findAllByOrderByNumberAsc(): List<Lab>
|
||||||
|
fun existsByNumberAndSemesterId(number: Int, semesterId: UUID): Boolean
|
||||||
}
|
}
|
||||||
@@ -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 ->
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- Content: chapters -> sections -> articles (public learning materials).
|
||||||
|
-- The previously unused courses table is replaced by the chapters/sections model.
|
||||||
|
|
||||||
|
ALTER TABLE articles DROP CONSTRAINT IF EXISTS articles_course_slug_unique;
|
||||||
|
ALTER TABLE articles DROP CONSTRAINT IF EXISTS articles_course_id_fkey;
|
||||||
|
ALTER TABLE articles RENAME COLUMN course_id TO section_id;
|
||||||
|
|
||||||
|
CREATE TABLE chapters (
|
||||||
|
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||||
|
title varchar(200) NOT NULL,
|
||||||
|
slug varchar(200) NOT NULL UNIQUE,
|
||||||
|
position integer NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sections (
|
||||||
|
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||||
|
chapter_id uuid NOT NULL REFERENCES chapters(id) ON DELETE CASCADE,
|
||||||
|
title varchar(200) NOT NULL,
|
||||||
|
slug varchar(200) NOT NULL,
|
||||||
|
position integer NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT sections_chapter_slug_unique UNIQUE (chapter_id, slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE articles
|
||||||
|
ADD CONSTRAINT articles_section_fk FOREIGN KEY (section_id) REFERENCES sections(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE articles
|
||||||
|
ADD CONSTRAINT articles_section_slug_unique UNIQUE (section_id, slug);
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS courses;
|
||||||
@@ -45,7 +45,8 @@ class JwtAuthenticationFilter(private val jwtDecoder: JwtDecoder) : GlobalFilter
|
|||||||
path == "/api/v1/auth/login" ||
|
path == "/api/v1/auth/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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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, "Сессия истекла, войдите заново")
|
||||||
|
}
|
||||||
@@ -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*\"((?:\\\\.|[^\"\\\\])*)\"")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
43
web/src/main/kotlin/ru/ysu/lms/web/config/CsrfInterceptor.kt
Normal file
43
web/src/main/kotlin/ru/ysu/lms/web/config/CsrfInterceptor.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
79
web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt
Normal file
79
web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt
Normal 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,
|
||||||
|
)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
7
web/src/main/resources/static/favicon.svg
Normal file
7
web/src/main/resources/static/favicon.svg
Normal 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 |
@@ -4,24 +4,51 @@
|
|||||||
<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="flex items-center justify-between mb-4">
|
<div class="grid grid-2 mt-8">
|
||||||
<h2 class="section-title">Группы</h2>
|
<div>
|
||||||
<a th:href="@{/admin/groups}" class="text-sm">Все группы →</a>
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="section-title">Лабораторные</h2>
|
||||||
|
<a th:href="@{/admin/labs}" class="text-sm">Все →</a>
|
||||||
|
</div>
|
||||||
|
<div class="card card-flush">
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr th:each="lab : ${labs}">
|
||||||
|
<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="card card-flush">
|
<div>
|
||||||
<table>
|
<div class="flex items-center justify-between mb-4">
|
||||||
<thead><tr><th>Название</th><th>Студентов</th></tr></thead>
|
<h2 class="section-title">Группы</h2>
|
||||||
<tbody>
|
<a th:href="@{/admin/groups}" class="text-sm">Все →</a>
|
||||||
<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>
|
</div>
|
||||||
<tr th:if="${#lists.isEmpty(groups)}"><td colspan="2" class="empty">Групп пока нет.</td></tr>
|
<div class="card card-flush">
|
||||||
</tbody>
|
<table>
|
||||||
</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>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
<table>
|
|
||||||
<thead><tr><th>Код</th><th>Название</th><th>Вариантов</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr th:each="level : ${lab.levels}">
|
|
||||||
<td class="font-mono" th:text="${level.code}">—</td>
|
|
||||||
<td th:text="${level.title}">—</td>
|
|
||||||
<td th:text="${#lists.size(level.variants)}">0</td>
|
|
||||||
</tr>
|
|
||||||
<tr th:if="${#lists.isEmpty(lab.levels)}"><td colspan="3" class="empty">Уровни не найдены.</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-4 mb-4">
|
<div class="flex items-center justify-between gap-4 mb-4">
|
||||||
<h2 class="section-title">Студенты и варианты</h2>
|
<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>
|
||||||
|
<thead><tr><th>№</th><th>Название</th><th>Условие</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr th:each="v : ${level.variants}">
|
||||||
|
<td class="font-mono" th:text="${v.number}">—</td>
|
||||||
|
<td th:text="${v.title}">—</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 th:if="${#lists.isEmpty(level.variants)}"><td colspan="4" class="empty">Вариантов пока нет.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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,13 +132,126 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
|
||||||
function filterGroups(name) {
|
|
||||||
document.querySelectorAll('.group-section').forEach(function (el) {
|
|
||||||
el.classList.toggle('hidden', !!name && el.getAttribute('data-group') !== name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</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>
|
||||||
|
function filterGroups(name) {
|
||||||
|
document.querySelectorAll('.group-section').forEach(function (el) {
|
||||||
|
el.classList.toggle('hidden', !!name && el.getAttribute('data-group') !== name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -3,18 +3,14 @@
|
|||||||
<head><title>Лабораторные — LMS</title></head>
|
<head><title>Лабораторные — LMS</title></head>
|
||||||
<body>
|
<body>
|
||||||
<div id="content">
|
<div id="content">
|
||||||
<h1 class="page-title">Лабораторные</h1>
|
<div class="flex items-center justify-between gap-4 mb-6">
|
||||||
|
<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; Основная группа 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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:if="${currentUser.admin}" th:href="@{/admin/groups}" class="nav-link">Группы</a>
|
<a th:href="@{/learn}" class="nav-link">Материалы</a>
|
||||||
<a th:if="${currentUser.admin}" th:href="@{/admin/labs}" class="nav-link">Лабораторные</a>
|
<th:block th:if="${currentUser != null}">
|
||||||
<span class="nav-sep" th:text="${currentUser.name}">—</span>
|
<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>
|
||||||
|
<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>
|
||||||
|
|||||||
18
web/src/main/resources/templates/learn/article.html
Normal file
18
web/src/main/resources/templates/learn/article.html
Normal 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>
|
||||||
195
web/src/main/resources/templates/learn/index.html
Normal file
195
web/src/main/resources/templates/learn/index.html
Normal 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>
|
||||||
23
web/src/main/resources/templates/learn/section.html
Normal file
23
web/src/main/resources/templates/learn/section.html
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user