Lint: strict mode on; tune detekt thresholds; more decompositions
This commit is contained in:
@@ -8,7 +8,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ktlint {
|
ktlint {
|
||||||
ignoreFailures.set(true)
|
ignoreFailures.set(false)
|
||||||
filter {
|
filter {
|
||||||
exclude("**/build/**")
|
exclude("**/build/**")
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@ ktlint {
|
|||||||
detekt {
|
detekt {
|
||||||
buildUponDefaultConfig = true
|
buildUponDefaultConfig = true
|
||||||
config.setFrom(rootProject.files("config/detekt/detekt.yml"))
|
config.setFrom(rootProject.files("config/detekt/detekt.yml"))
|
||||||
ignoreFailures = true
|
ignoreFailures = false
|
||||||
parallel = true
|
parallel = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,20 +9,31 @@ config:
|
|||||||
warningsAsErrors: false
|
warningsAsErrors: false
|
||||||
|
|
||||||
complexity:
|
complexity:
|
||||||
|
# Пороги выставлены под реальный масштаб классов проекта. Дробление крупных
|
||||||
|
# агрегатов (web ApiClient, AdminController, ContentService) — отдельная задача;
|
||||||
|
# когда оно будет выполнено, пороги можно вернуть к значениям по умолчанию.
|
||||||
LongMethod:
|
LongMethod:
|
||||||
threshold: 60
|
threshold: 90
|
||||||
LongParameterList:
|
LongParameterList:
|
||||||
functionThreshold: 6
|
functionThreshold: 8
|
||||||
constructorThreshold: 7
|
constructorThreshold: 8
|
||||||
ignoreDefaultParameters: true
|
ignoreDefaultParameters: true
|
||||||
|
# MVC-обработчики форм принимают набор @RequestParam — для них параметров «много» нормально.
|
||||||
|
ignoreAnnotated:
|
||||||
|
- 'PostMapping'
|
||||||
|
- 'GetMapping'
|
||||||
TooManyFunctions:
|
TooManyFunctions:
|
||||||
thresholdInFiles: 20
|
thresholdInFiles: 80
|
||||||
thresholdInClasses: 20
|
thresholdInClasses: 80
|
||||||
thresholdInInterfaces: 20
|
thresholdInInterfaces: 40
|
||||||
thresholdInObjects: 20
|
thresholdInObjects: 40
|
||||||
thresholdInEnums: 20
|
thresholdInEnums: 40
|
||||||
|
LargeClass:
|
||||||
|
threshold: 1400
|
||||||
ComplexCondition:
|
ComplexCondition:
|
||||||
threshold: 4
|
threshold: 4
|
||||||
|
CyclomaticComplexMethod:
|
||||||
|
threshold: 20
|
||||||
|
|
||||||
style:
|
style:
|
||||||
MagicNumber:
|
MagicNumber:
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
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 java.io.ByteArrayInputStream
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Импорт материалов из zip со структурой каталогов:
|
||||||
|
* `chapters/01-глава/01-раздел/01-статья.md` (front-matter: `title`, `slug`),
|
||||||
|
* картинки — в `assets/`.
|
||||||
|
*
|
||||||
|
* Главы, разделы и статьи сопоставляются по названию (запасной вариант — по slug),
|
||||||
|
* поэтому повторный импорт не плодит дубли и не затирает материалы, созданные вручную.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class ContentImporter(
|
||||||
|
private val chapters: ContentChapterRepository,
|
||||||
|
private val sections: ContentSectionRepository,
|
||||||
|
private val articles: ContentArticleRepository,
|
||||||
|
private val assets: AssetStorage,
|
||||||
|
) {
|
||||||
|
@Transactional
|
||||||
|
fun importZip(
|
||||||
|
filename: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
sync: Boolean = true,
|
||||||
|
): ContentImportResult {
|
||||||
|
requireZip(filename)
|
||||||
|
val files = readZip(bytes)
|
||||||
|
if (files.isEmpty()) throw BadRequestException("Архив пуст")
|
||||||
|
|
||||||
|
val contentFiles = files.filterKeys(::isContentPath)
|
||||||
|
if (contentFiles.isEmpty()) {
|
||||||
|
throw BadRequestException("В архиве нет каталога chapters/ с .md файлами")
|
||||||
|
}
|
||||||
|
val linkRewrite = storeAssets(files.filterKeys(::isAssetsPath))
|
||||||
|
val parsed = parseArticles(contentFiles)
|
||||||
|
return mergeIntoDb(parsed, linkRewrite, sync)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireZip(filename: String) {
|
||||||
|
if (!filename.lowercase().endsWith(".zip")) throw BadRequestException("Ожидается .zip архив")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mergeIntoDb(
|
||||||
|
parsed: List<ParsedArticle>,
|
||||||
|
linkRewrite: Map<String, String>,
|
||||||
|
sync: Boolean,
|
||||||
|
): ContentImportResult {
|
||||||
|
val state = ImportState()
|
||||||
|
for (p in parsed) {
|
||||||
|
val chapter = chapterFor(p, state)
|
||||||
|
val section = sectionFor(chapter, p, state)
|
||||||
|
val body = linkRewrite.entries.fold(p.body) { acc, (from, to) -> acc.replace(from, to) }
|
||||||
|
upsertArticle(section, p, body, state)
|
||||||
|
}
|
||||||
|
val removed = if (sync) removeMissing(state) else 0
|
||||||
|
return ContentImportResult(
|
||||||
|
chapters = state.chapters,
|
||||||
|
sections = state.sections,
|
||||||
|
articles = state.articles,
|
||||||
|
assets = linkRewrite.size,
|
||||||
|
removed = removed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chapterFor(
|
||||||
|
p: ParsedArticle,
|
||||||
|
state: ImportState,
|
||||||
|
): ContentChapter {
|
||||||
|
if (state.chapter?.title != p.chapterDir) {
|
||||||
|
val slug = Slugs.slugify(p.chapterDir) ?: "chapter-${state.chapters + 1}"
|
||||||
|
val existing = chapters.findByTitleIgnoreCase(p.chapterDir) ?: chapters.findBySlug(slug)
|
||||||
|
val entity =
|
||||||
|
existing?.apply {
|
||||||
|
title = p.chapterDir
|
||||||
|
position = p.chapterPos
|
||||||
|
} ?: ContentChapter(
|
||||||
|
title = p.chapterDir,
|
||||||
|
slug = Slugs.uniqueSlug(p.chapterDir, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()),
|
||||||
|
position = p.chapterPos,
|
||||||
|
)
|
||||||
|
state.chapter = chapters.save(entity)
|
||||||
|
state.touchedChapters.add(state.chapter!!.id)
|
||||||
|
state.chapters += 1
|
||||||
|
state.section = null
|
||||||
|
}
|
||||||
|
return state.chapter ?: throw BadRequestException("Не удалось определить главу")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sectionFor(
|
||||||
|
chapter: ContentChapter,
|
||||||
|
p: ParsedArticle,
|
||||||
|
state: ImportState,
|
||||||
|
): ContentSection {
|
||||||
|
if (state.section?.title != p.sectionDir) {
|
||||||
|
val slug = Slugs.slugify(p.sectionDir) ?: "section-${state.sections + 1}"
|
||||||
|
val existing =
|
||||||
|
sections.findByChapterIdAndTitleIgnoreCase(chapter.id, p.sectionDir)
|
||||||
|
?: sections.findByChapterIdAndSlug(chapter.id, slug)
|
||||||
|
val entity =
|
||||||
|
existing?.apply {
|
||||||
|
title = p.sectionDir
|
||||||
|
position = p.sectionPos
|
||||||
|
} ?: ContentSection(
|
||||||
|
chapterId = chapter.id,
|
||||||
|
title = p.sectionDir,
|
||||||
|
slug =
|
||||||
|
Slugs.uniqueSlug(
|
||||||
|
p.sectionDir,
|
||||||
|
sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id).map { it.slug }.toSet(),
|
||||||
|
),
|
||||||
|
position = p.sectionPos,
|
||||||
|
)
|
||||||
|
state.section = sections.save(entity)
|
||||||
|
state.seenSections.add(state.section!!.id)
|
||||||
|
state.sections += 1
|
||||||
|
}
|
||||||
|
return state.section ?: throw BadRequestException("Не удалось определить раздел")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun upsertArticle(
|
||||||
|
section: ContentSection,
|
||||||
|
p: ParsedArticle,
|
||||||
|
body: String,
|
||||||
|
state: ImportState,
|
||||||
|
) {
|
||||||
|
val slug = p.fmSlug?.takeIf { it.isNotBlank() } ?: Slugs.slugify(p.fileTitle) ?: "article-${state.articles + 1}"
|
||||||
|
val existing =
|
||||||
|
articles.findBySectionIdAndTitleIgnoreCase(section.id, p.fileTitle)
|
||||||
|
?: articles.findBySectionIdAndSlug(section.id, slug)
|
||||||
|
val article =
|
||||||
|
(existing ?: ContentArticle(sectionId = section.id, slug = slug)).apply {
|
||||||
|
title = p.fileTitle
|
||||||
|
bodyMd = body
|
||||||
|
position = p.articlePos
|
||||||
|
}
|
||||||
|
articles.save(article)
|
||||||
|
state.seenArticles.add(article.id)
|
||||||
|
state.articles += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** При синхронизации удаляет разделы и статьи главы, которых не было в архиве. */
|
||||||
|
private fun removeMissing(state: ImportState): Int =
|
||||||
|
state.touchedChapters.sumOf { chapterId ->
|
||||||
|
sections
|
||||||
|
.findByChapterIdOrderByPositionAscIdAsc(chapterId)
|
||||||
|
.sumOf { section -> removeMissingInSection(section, state) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeMissingInSection(
|
||||||
|
section: ContentSection,
|
||||||
|
state: ImportState,
|
||||||
|
): Int {
|
||||||
|
if (section.id !in state.seenSections) {
|
||||||
|
val removedCount = articles.findBySectionIdOrderByPositionAscIdAsc(section.id).size + 1
|
||||||
|
sections.delete(section)
|
||||||
|
return removedCount
|
||||||
|
}
|
||||||
|
var removed = 0
|
||||||
|
for (article in articles.findBySectionIdOrderByPositionAscIdAsc(section.id)) {
|
||||||
|
if (article.id !in state.seenArticles) {
|
||||||
|
articles.delete(article)
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun storeAssets(assetFiles: Map<String, ByteArray>): Map<String, String> {
|
||||||
|
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}"
|
||||||
|
}
|
||||||
|
return linkRewrite
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAssetsPath(path: String): Boolean = path.contains("/assets/") || path.startsWith("assets/")
|
||||||
|
|
||||||
|
private fun isContentPath(path: String): Boolean = (path.contains("/chapters/") || path.startsWith("chapters/")) && path.endsWith(".md")
|
||||||
|
|
||||||
|
private fun parseArticles(contentFiles: Map<String, ByteArray>): List<ParsedArticle> =
|
||||||
|
contentFiles
|
||||||
|
.map { (path, data) -> parseArticle(path, data) }
|
||||||
|
.sortedWith(
|
||||||
|
compareBy(
|
||||||
|
{ it.chapterPos },
|
||||||
|
{ it.chapterDir },
|
||||||
|
{ it.sectionPos },
|
||||||
|
{ it.sectionDir },
|
||||||
|
{ it.articlePos },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun parseArticle(
|
||||||
|
path: String,
|
||||||
|
data: ByteArray,
|
||||||
|
): ParsedArticle {
|
||||||
|
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 (frontMatter, body) = splitFrontMatter(String(data, Charsets.UTF_8))
|
||||||
|
return ParsedArticle(
|
||||||
|
chapterPos = Slugs.positionOf(chapterDir),
|
||||||
|
chapterDir = Slugs.titleFromDir(chapterDir),
|
||||||
|
sectionPos = Slugs.positionOf(sectionDir),
|
||||||
|
sectionDir = Slugs.titleFromDir(sectionDir),
|
||||||
|
articlePos = Slugs.positionOf(fileName),
|
||||||
|
fileTitle = frontMatter["title"] ?: Slugs.titleFromDir(fileName),
|
||||||
|
fmSlug = frontMatter["slug"],
|
||||||
|
body = body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 frontMatter = mutableMapOf<String, String>()
|
||||||
|
for (line in lines.subList(1, end)) {
|
||||||
|
val parts = line.split(":", limit = 2)
|
||||||
|
if (parts.size == 2) frontMatter[parts[0].trim()] = unquote(parts[1].trim())
|
||||||
|
}
|
||||||
|
return frontMatter to lines.drop(end + 1).joinToString("\n").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unquote(value: String): String =
|
||||||
|
if (value.length >= 2 && value.first() == '"' && value.last() == '"') {
|
||||||
|
value.substring(1, value.length - 1)
|
||||||
|
} else {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ParsedArticle(
|
||||||
|
val chapterPos: Int,
|
||||||
|
val chapterDir: String,
|
||||||
|
val sectionPos: Int,
|
||||||
|
val sectionDir: String,
|
||||||
|
val articlePos: Int,
|
||||||
|
val fileTitle: String,
|
||||||
|
val fmSlug: String?,
|
||||||
|
val body: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private class ImportState {
|
||||||
|
var chapter: ContentChapter? = null
|
||||||
|
var section: ContentSection? = null
|
||||||
|
var chapters: Int = 0
|
||||||
|
var sections: Int = 0
|
||||||
|
var articles: Int = 0
|
||||||
|
val touchedChapters: MutableSet<UUID> = mutableSetOf()
|
||||||
|
val seenSections: MutableSet<UUID> = mutableSetOf()
|
||||||
|
val seenArticles: MutableSet<UUID> = mutableSetOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,7 @@ import ru.ysu.lms.academic.repo.ContentChapterRepository
|
|||||||
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||||
import ru.ysu.lms.common.BadRequestException
|
import ru.ysu.lms.common.BadRequestException
|
||||||
import ru.ysu.lms.common.NotFoundException
|
import ru.ysu.lms.common.NotFoundException
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.zip.ZipInputStream
|
|
||||||
|
|
||||||
data class ArticleTreeNode(
|
data class ArticleTreeNode(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -98,24 +96,12 @@ data class ReorderArticles(
|
|||||||
val articles: List<ReorderArticle> = emptyList(),
|
val articles: List<ReorderArticle> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
@Service
|
||||||
class ContentService(
|
class ContentService(
|
||||||
private val chapters: ContentChapterRepository,
|
private val chapters: ContentChapterRepository,
|
||||||
private val sections: ContentSectionRepository,
|
private val sections: ContentSectionRepository,
|
||||||
private val articles: ContentArticleRepository,
|
private val articles: ContentArticleRepository,
|
||||||
private val assets: AssetStorage,
|
private val importer: ContentImporter,
|
||||||
) {
|
) {
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun tree(includeHidden: Boolean = false): ContentTree {
|
fun tree(includeHidden: Boolean = false): ContentTree {
|
||||||
@@ -349,8 +335,8 @@ class ContentService(
|
|||||||
): ChapterTreeNode {
|
): ChapterTreeNode {
|
||||||
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
|
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
|
||||||
val cleanSlug =
|
val cleanSlug =
|
||||||
slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
|
slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
|
||||||
?: uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
|
?: Slugs.uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
|
||||||
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
|
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
|
||||||
val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1))
|
val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1))
|
||||||
return ChapterTreeNode(
|
return ChapterTreeNode(
|
||||||
@@ -390,8 +376,8 @@ class ContentService(
|
|||||||
if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым")
|
if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым")
|
||||||
val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id)
|
val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id)
|
||||||
val cleanSlug =
|
val cleanSlug =
|
||||||
slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
|
slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
|
||||||
?: uniqueSlug(title, siblings.map { it.slug }.toSet())
|
?: Slugs.uniqueSlug(title, siblings.map { it.slug }.toSet())
|
||||||
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
|
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
|
||||||
val sec =
|
val sec =
|
||||||
sections.save(
|
sections.save(
|
||||||
@@ -505,7 +491,7 @@ class ContentService(
|
|||||||
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||||
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
|
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
|
||||||
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
|
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
|
||||||
val slug = uniqueSlug(title, siblings.map { it.slug }.toSet())
|
val slug = Slugs.uniqueSlug(title, siblings.map { it.slug }.toSet())
|
||||||
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
|
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
|
||||||
val a =
|
val a =
|
||||||
articles.save(
|
articles.save(
|
||||||
@@ -547,297 +533,10 @@ class ContentService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
@Transactional
|
||||||
fun importZip(
|
fun importZip(
|
||||||
filename: String,
|
filename: String,
|
||||||
bytes: ByteArray,
|
bytes: ByteArray,
|
||||||
sync: Boolean = true,
|
sync: Boolean = true,
|
||||||
): ContentImportResult {
|
): ContentImportResult = importer.importZip(filename, bytes, sync)
|
||||||
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 removedCount = 0
|
|
||||||
var currentChapter: ContentChapter? = null
|
|
||||||
var currentSection: ContentSection? = null
|
|
||||||
val touchedChapters = mutableSetOf<UUID>()
|
|
||||||
val seenSections = mutableSetOf<UUID>()
|
|
||||||
val seenArticles = mutableSetOf<UUID>()
|
|
||||||
|
|
||||||
for (p in parsed) {
|
|
||||||
if (currentChapter?.title != p.chapterDir) {
|
|
||||||
val slug = chapterSlug(p) ?: "chapter-${chCount + 1}"
|
|
||||||
// Ищем по названию, затем по slug: так импорт находит главу, созданную
|
|
||||||
// вручную или импортированную ранее, и не плодит дубли.
|
|
||||||
currentChapter = (chapters.findByTitleIgnoreCase(p.chapterDir) ?: chapters.findBySlug(slug))?.apply {
|
|
||||||
title = p.chapterDir
|
|
||||||
position = p.chapterPos
|
|
||||||
} ?: chapters.save(
|
|
||||||
ContentChapter(
|
|
||||||
title = p.chapterDir,
|
|
||||||
slug = uniqueSlug(p.chapterDir, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()),
|
|
||||||
position = p.chapterPos,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
currentChapter = chapters.save(currentChapter!!)
|
|
||||||
touchedChapters.add(currentChapter!!.id)
|
|
||||||
chCount += 1
|
|
||||||
currentSection = null
|
|
||||||
}
|
|
||||||
val chapter = currentChapter ?: throw BadRequestException("Не удалось определить главу")
|
|
||||||
if (currentSection?.title != p.sectionDir) {
|
|
||||||
val slug = sectionSlug(p) ?: "section-${secCount + 1}"
|
|
||||||
currentSection = (
|
|
||||||
sections.findByChapterIdAndTitleIgnoreCase(chapter.id, p.sectionDir)
|
|
||||||
?: sections.findByChapterIdAndSlug(chapter.id, slug)
|
|
||||||
)?.apply {
|
|
||||||
title = p.sectionDir
|
|
||||||
position = p.sectionPos
|
|
||||||
} ?: sections.save(
|
|
||||||
ContentSection(
|
|
||||||
chapterId = chapter.id,
|
|
||||||
title = p.sectionDir,
|
|
||||||
slug =
|
|
||||||
uniqueSlug(
|
|
||||||
p.sectionDir,
|
|
||||||
sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id).map { it.slug }.toSet(),
|
|
||||||
),
|
|
||||||
position = p.sectionPos,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
currentSection = sections.save(currentSection!!)
|
|
||||||
seenSections.add(currentSection!!.id)
|
|
||||||
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.findBySectionIdAndTitleIgnoreCase(section.id, p.fileTitle)
|
|
||||||
?: articles.findBySectionIdAndSlug(section.id, slug)
|
|
||||||
val article =
|
|
||||||
(existing ?: ContentArticle(sectionId = section.id, slug = slug)).apply {
|
|
||||||
title = p.fileTitle
|
|
||||||
bodyMd = body
|
|
||||||
position = p.articlePos
|
|
||||||
}
|
|
||||||
articles.save(article)
|
|
||||||
seenArticles.add(article.id)
|
|
||||||
artCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Синхронизация (если включена): в главах из архива удаляем разделы и статьи,
|
|
||||||
// которых в архиве нет. В режиме merge (sync = false) ничего не удаляем —
|
|
||||||
// удобно догружать отдельные статьи, не затрагивая правки, сделанные в интерфейсе.
|
|
||||||
for (chapterId in if (sync) touchedChapters else emptySet()) {
|
|
||||||
for (section in sections.findByChapterIdOrderByPositionAscIdAsc(chapterId)) {
|
|
||||||
if (section.id in seenSections) {
|
|
||||||
for (article in articles.findBySectionIdOrderByPositionAscIdAsc(section.id)) {
|
|
||||||
if (article.id !in seenArticles) {
|
|
||||||
articles.delete(article)
|
|
||||||
removedCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
removedCount += articles.findBySectionIdOrderByPositionAscIdAsc(section.id).size + 1
|
|
||||||
sections.delete(section)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ContentImportResult(
|
|
||||||
chapters = chCount,
|
|
||||||
sections = secCount,
|
|
||||||
articles = artCount,
|
|
||||||
assets = linkRewrite.size,
|
|
||||||
removed = removedCount,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slug без числового префикса — такой же, как при ручном создании через интерфейс.
|
|
||||||
// Номер каталога по-прежнему задаёт порядок (position), но не попадает в адрес.
|
|
||||||
private fun chapterSlug(p: ParsedArticle): String? = slugify(p.chapterDir)
|
|
||||||
|
|
||||||
private fun sectionSlug(p: ParsedArticle): String? = slugify(p.sectionDir)
|
|
||||||
|
|
||||||
private fun articleSlug(p: ParsedArticle): String? = slugify(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,81 @@
|
|||||||
|
package ru.ysu.lms.academic.content
|
||||||
|
|
||||||
|
/** Транслитерация и построение slug для глав, разделов и статей. */
|
||||||
|
object Slugs {
|
||||||
|
private 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",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** «01-Ввод-вывод» -> «vvod-vyvod»; null, если не осталось допустимых символов. */
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Уникальный slug на основе заголовка: добавляет «-2», «-3», если базовый занят. */
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Числовой префикс каталога («02-Матрицы» -> 2); 999, если префикса нет. */
|
||||||
|
fun positionOf(name: String): Int {
|
||||||
|
val match = Regex("^(\\d{1,3})[-_]").find(name.substringAfterLast('/'))
|
||||||
|
return match?.groupValues?.get(1)?.toIntOrNull() ?: 999
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «02-Матрицы» -> «Матрицы» (без числового префикса, подчёркивания → пробелы). */
|
||||||
|
fun titleFromDir(dir: String): String {
|
||||||
|
val raw =
|
||||||
|
dir
|
||||||
|
.substringAfterLast('/')
|
||||||
|
.replace(Regex("^\\d{1,3}[-_]"), "")
|
||||||
|
.replace('_', ' ')
|
||||||
|
return raw.ifBlank { dir }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,63 +159,77 @@ class GroupService(
|
|||||||
val updated = mutableListOf<String>()
|
val updated = mutableListOf<String>()
|
||||||
|
|
||||||
parsed.students.forEach { parsedStudent ->
|
parsed.students.forEach { parsedStudent ->
|
||||||
val existing = students.findByStudentNo(parsedStudent.studentNo)
|
val student = upsertStudent(parsedStudent, added, updated)
|
||||||
val student =
|
enrollIntoGroup(group, student)
|
||||||
if (existing != null) {
|
|
||||||
existing.lastName = parsedStudent.lastName
|
|
||||||
existing.firstName = parsedStudent.firstName
|
|
||||||
existing.middleName = parsedStudent.middleName
|
|
||||||
existing.basis = parsedStudent.basis
|
|
||||||
existing.note = parsedStudent.note
|
|
||||||
students.save(existing)
|
|
||||||
updated.add(parsedStudent.studentNo)
|
|
||||||
existing
|
|
||||||
} else {
|
|
||||||
val password = Passwords.generate()
|
|
||||||
val account =
|
|
||||||
accounts.save(
|
|
||||||
Account(
|
|
||||||
login = parsedStudent.studentNo,
|
|
||||||
passwordHash = passwordEncoder.encode(password) ?: "",
|
|
||||||
role = LmsRole.STUDENT,
|
|
||||||
mustChangePassword = true,
|
|
||||||
tempPassword = password,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val created =
|
|
||||||
Student(
|
|
||||||
studentNo = parsedStudent.studentNo,
|
|
||||||
lastName = parsedStudent.lastName,
|
|
||||||
firstName = parsedStudent.firstName,
|
|
||||||
middleName = parsedStudent.middleName,
|
|
||||||
basis = parsedStudent.basis,
|
|
||||||
note = parsedStudent.note,
|
|
||||||
)
|
|
||||||
created.account = account
|
|
||||||
students.save(created)
|
|
||||||
added.add(
|
|
||||||
Credential(
|
|
||||||
id = account.id.toString(),
|
|
||||||
studentNo = parsedStudent.studentNo,
|
|
||||||
fullName = parsedStudent.fullName,
|
|
||||||
password = password,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
created
|
|
||||||
}
|
|
||||||
|
|
||||||
val accountId = student.accountId!!
|
|
||||||
val enrollment = enrollments.findByStudentAccountId(accountId)
|
|
||||||
if (enrollment == null) {
|
|
||||||
enrollments.save(Enrollment(group = group, student = student))
|
|
||||||
} else {
|
|
||||||
enrollment.group = group
|
|
||||||
enrollments.save(enrollment)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return ImportResult(group.id.toString(), group.name, added, updated)
|
return ImportResult(group.id.toString(), group.name, added, updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Обновляет существующего студента или создаёт нового с временным паролем. */
|
||||||
|
private fun upsertStudent(
|
||||||
|
parsed: ParsedStudent,
|
||||||
|
added: MutableList<Credential>,
|
||||||
|
updated: MutableList<String>,
|
||||||
|
): Student {
|
||||||
|
val existing = students.findByStudentNo(parsed.studentNo)
|
||||||
|
if (existing != null) {
|
||||||
|
existing.lastName = parsed.lastName
|
||||||
|
existing.firstName = parsed.firstName
|
||||||
|
existing.middleName = parsed.middleName
|
||||||
|
existing.basis = parsed.basis
|
||||||
|
existing.note = parsed.note
|
||||||
|
students.save(existing)
|
||||||
|
updated.add(parsed.studentNo)
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
val password = Passwords.generate()
|
||||||
|
val account =
|
||||||
|
accounts.save(
|
||||||
|
Account(
|
||||||
|
login = parsed.studentNo,
|
||||||
|
passwordHash = passwordEncoder.encode(password) ?: "",
|
||||||
|
role = LmsRole.STUDENT,
|
||||||
|
mustChangePassword = true,
|
||||||
|
tempPassword = password,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val created =
|
||||||
|
Student(
|
||||||
|
studentNo = parsed.studentNo,
|
||||||
|
lastName = parsed.lastName,
|
||||||
|
firstName = parsed.firstName,
|
||||||
|
middleName = parsed.middleName,
|
||||||
|
basis = parsed.basis,
|
||||||
|
note = parsed.note,
|
||||||
|
)
|
||||||
|
created.account = account
|
||||||
|
students.save(created)
|
||||||
|
added.add(
|
||||||
|
Credential(
|
||||||
|
id = account.id.toString(),
|
||||||
|
studentNo = parsed.studentNo,
|
||||||
|
fullName = parsed.fullName,
|
||||||
|
password = password,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Зачисляет студента в группу (или переводит из другой). */
|
||||||
|
private fun enrollIntoGroup(
|
||||||
|
group: GroupEntity,
|
||||||
|
student: Student,
|
||||||
|
) {
|
||||||
|
val accountId = student.accountId!!
|
||||||
|
val enrollment = enrollments.findByStudentAccountId(accountId)
|
||||||
|
if (enrollment == null) {
|
||||||
|
enrollments.save(Enrollment(group = group, student = student))
|
||||||
|
} else {
|
||||||
|
enrollment.group = group
|
||||||
|
enrollments.save(enrollment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun groupOfStudent(accountId: UUID): GroupSummary? {
|
fun groupOfStudent(accountId: UUID): GroupSummary? {
|
||||||
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null
|
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null
|
||||||
|
|||||||
@@ -94,6 +94,34 @@ class ProgressService(
|
|||||||
request: DistributeRequest,
|
request: DistributeRequest,
|
||||||
token: String,
|
token: String,
|
||||||
): DistributeResult {
|
): DistributeResult {
|
||||||
|
val labIds = resolveLabIds(request)
|
||||||
|
val force = request.force == true
|
||||||
|
val allStudents = collectStudentIds(request, token)
|
||||||
|
var assignedTotal = 0
|
||||||
|
var totalStudents = 0
|
||||||
|
var lastLabId: String? = null
|
||||||
|
var lastLabTitle: String? = null
|
||||||
|
for (labIdStr in labIds) {
|
||||||
|
val labId = UUID.fromString(labIdStr)
|
||||||
|
val lab = academic.getLab(labIdStr, token)
|
||||||
|
if (force) clearAutoAssignments(labId, allStudents)
|
||||||
|
val kept = keepExisting(labId, allStudents)
|
||||||
|
assignedTotal += kept.size
|
||||||
|
assignedTotal += assignNew(lab, labId, allStudents.filterNot { it in kept })
|
||||||
|
totalStudents += allStudents.size
|
||||||
|
lastLabId = labIdStr
|
||||||
|
lastLabTitle = lab.title
|
||||||
|
}
|
||||||
|
return DistributeResult(
|
||||||
|
labs = labIds.size,
|
||||||
|
assigned = assignedTotal,
|
||||||
|
total = totalStudents,
|
||||||
|
labId = if (labIds.size == 1) lastLabId else null,
|
||||||
|
labTitle = if (labIds.size == 1) lastLabTitle else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveLabIds(request: DistributeRequest): List<String> {
|
||||||
val labIds =
|
val labIds =
|
||||||
request.labIds
|
request.labIds
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
@@ -102,8 +130,13 @@ class ProgressService(
|
|||||||
.filter { it.isNotBlank() }
|
.filter { it.isNotBlank() }
|
||||||
.distinct()
|
.distinct()
|
||||||
if (labIds.isEmpty()) throw BadRequestException("Не указана ни одна лабораторная")
|
if (labIds.isEmpty()) throw BadRequestException("Не указана ни одна лабораторная")
|
||||||
|
return labIds
|
||||||
|
}
|
||||||
|
|
||||||
val force = request.force == true
|
private fun collectStudentIds(
|
||||||
|
request: DistributeRequest,
|
||||||
|
token: String,
|
||||||
|
): List<UUID> {
|
||||||
val groupFilter =
|
val groupFilter =
|
||||||
request.groupIds
|
request.groupIds
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
@@ -113,94 +146,75 @@ class ProgressService(
|
|||||||
for (g in identity.listGroups(token)) {
|
for (g in identity.listGroups(token)) {
|
||||||
val gid = g["id"] as String
|
val gid = g["id"] as String
|
||||||
if (groupFilter.isNotEmpty() && gid !in groupFilter) continue
|
if (groupFilter.isNotEmpty() && gid !in groupFilter) continue
|
||||||
for (s in identity.getGroup(gid, token).students) {
|
for (s in identity.getGroup(gid, token).students) studentIds.add(UUID.fromString(s.id))
|
||||||
studentIds.add(UUID.fromString(s.id))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val allStudents = studentIds.distinct()
|
return studentIds.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
var assignedTotal = 0
|
private fun clearAutoAssignments(
|
||||||
var totalStudents = 0
|
labId: UUID,
|
||||||
var lastLabId: String? = null
|
allStudents: List<UUID>,
|
||||||
var lastLabTitle: String? = null
|
) {
|
||||||
|
assignments
|
||||||
|
.findByLabId(labId)
|
||||||
|
.filter { it.studentId in allStudents && !it.manual }
|
||||||
|
.forEach { assignments.delete(it) }
|
||||||
|
}
|
||||||
|
|
||||||
for (labIdStr in labIds) {
|
private fun keepExisting(
|
||||||
val labId = UUID.fromString(labIdStr)
|
labId: UUID,
|
||||||
val lab = academic.getLab(labIdStr, token)
|
allStudents: List<UUID>,
|
||||||
|
): Map<UUID, VariantAssignment> {
|
||||||
|
val kept = mutableMapOf<UUID, VariantAssignment>()
|
||||||
|
assignments
|
||||||
|
.findByLabId(labId)
|
||||||
|
.filter { it.studentId in allStudents }
|
||||||
|
.forEach { kept[it.studentId] = it }
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
// force сбрасывает авто-назначения только у выбранных студентов;
|
/** Выдаёт варианты студентам без назначения; очередь общая, повторы — только когда пул исчерпан. */
|
||||||
// закреплённые вручную варианты остаются как есть.
|
private fun assignNew(
|
||||||
if (force) {
|
lab: LabInfo,
|
||||||
assignments
|
labId: UUID,
|
||||||
.findByLabId(labId)
|
toAssign: List<UUID>,
|
||||||
.filter { it.studentId in allStudents && !it.manual }
|
): Int {
|
||||||
.forEach { assignments.delete(it) }
|
val poolsByLevel = lab.levels.associate { level -> level.id to level.variants.map { it.id } }
|
||||||
}
|
val defaultLevel = lab.levels.firstOrNull()?.id
|
||||||
|
if (toAssign.isEmpty() || poolsByLevel.isEmpty()) return 0
|
||||||
val kept = mutableMapOf<UUID, VariantAssignment>()
|
val usage =
|
||||||
assignedTotal += assignments.findByLabId(labId).count { it.studentId in allStudents }
|
|
||||||
assignments
|
assignments
|
||||||
.findByLabId(labId)
|
.findByLabId(labId)
|
||||||
.filter { it.studentId in allStudents }
|
.groupingBy { it.variantId.toString() }
|
||||||
.forEach { kept[it.studentId] = it }
|
.eachCount()
|
||||||
|
.toMutableMap()
|
||||||
val toAssign = allStudents.filterNot { kept.containsKey(it) }
|
var assigned = 0
|
||||||
|
for (sid in toAssign.shuffled()) {
|
||||||
// Уровень ограничивает пул: студент получает вариант только СВОЕГО уровня.
|
val levelId = levels.findByStudentIdAndLabId(sid, labId)?.labLevelId?.toString() ?: defaultLevel
|
||||||
// При этом очередь одна общая для всех выбранных групп: круг не
|
val pool = pickPool(levelId, poolsByLevel, defaultLevel)
|
||||||
// «перезапускается» на каждой группе, а продолжается по наименее
|
if (pool.isEmpty()) continue
|
||||||
// занятым вариантам уровня (пока хоть один не выдан — повторов нет).
|
val minCount = pool.minOf { id -> usage[id] ?: 0 }
|
||||||
val poolsByLevel = lab.levels.associate { lv -> lv.id to lv.variants.map { it.id } }
|
val chosen = pool.filter { id -> (usage[id] ?: 0) == minCount }.random()
|
||||||
val defaultLevel = lab.levels.firstOrNull()?.id
|
assignments.save(
|
||||||
|
VariantAssignment(studentId = sid, labId = labId, variantId = UUID.fromString(chosen), manual = false),
|
||||||
if (toAssign.isNotEmpty() && poolsByLevel.isNotEmpty()) {
|
)
|
||||||
val usage =
|
usage[chosen] = (usage[chosen] ?: 0) + 1
|
||||||
assignments
|
assigned++
|
||||||
.findByLabId(labId)
|
|
||||||
.groupingBy { it.variantId.toString() }
|
|
||||||
.eachCount()
|
|
||||||
.toMutableMap()
|
|
||||||
|
|
||||||
for (sid in toAssign.shuffled()) {
|
|
||||||
val levelId =
|
|
||||||
levels.findByStudentIdAndLabId(sid, labId)?.labLevelId?.toString()
|
|
||||||
?: defaultLevel
|
|
||||||
val pool =
|
|
||||||
levelId
|
|
||||||
?.let { poolsByLevel[it] }
|
|
||||||
.orEmpty()
|
|
||||||
.ifEmpty { defaultLevel?.let { poolsByLevel[it] }.orEmpty() }
|
|
||||||
.ifEmpty { poolsByLevel.values.firstOrNull { it.isNotEmpty() }.orEmpty() }
|
|
||||||
if (pool.isEmpty()) continue
|
|
||||||
val minCount = pool.minOf { usage[it] ?: 0 }
|
|
||||||
val chosen = pool.filter { (usage[it] ?: 0) == minCount }.random()
|
|
||||||
assignments.save(
|
|
||||||
VariantAssignment(
|
|
||||||
studentId = sid,
|
|
||||||
labId = labId,
|
|
||||||
variantId = UUID.fromString(chosen),
|
|
||||||
manual = false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
usage[chosen] = (usage[chosen] ?: 0) + 1
|
|
||||||
assignedTotal++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
totalStudents += allStudents.size
|
|
||||||
lastLabId = labIdStr
|
|
||||||
lastLabTitle = lab.title
|
|
||||||
}
|
}
|
||||||
|
return assigned
|
||||||
return DistributeResult(
|
|
||||||
labs = labIds.size,
|
|
||||||
assigned = assignedTotal,
|
|
||||||
total = totalStudents,
|
|
||||||
labId = if (labIds.size == 1) lastLabId else null,
|
|
||||||
labTitle = if (labIds.size == 1) lastLabTitle else null,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun pickPool(
|
||||||
|
levelId: String?,
|
||||||
|
poolsByLevel: Map<String, List<String>>,
|
||||||
|
defaultLevel: String?,
|
||||||
|
): List<String> =
|
||||||
|
levelId
|
||||||
|
?.let { poolsByLevel[it] }
|
||||||
|
.orEmpty()
|
||||||
|
.ifEmpty { defaultLevel?.let { poolsByLevel[it] }.orEmpty() }
|
||||||
|
.ifEmpty { poolsByLevel.values.firstOrNull { it.isNotEmpty() }.orEmpty() }
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> = assignments.findByLabId(labId)
|
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> = assignments.findByLabId(labId)
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,16 @@ import ru.ysu.lms.web.api.LabUpdatePayload
|
|||||||
import ru.ysu.lms.web.api.LevelAssignPayload
|
import ru.ysu.lms.web.api.LevelAssignPayload
|
||||||
import ru.ysu.lms.web.api.LevelPayload
|
import ru.ysu.lms.web.api.LevelPayload
|
||||||
import ru.ysu.lms.web.api.LevelSavePayload
|
import ru.ysu.lms.web.api.LevelSavePayload
|
||||||
|
import ru.ysu.lms.web.api.StudentRowDto
|
||||||
import ru.ysu.lms.web.api.VariantSavePayload
|
import ru.ysu.lms.web.api.VariantSavePayload
|
||||||
import ru.ysu.lms.web.session.SessionKeys
|
import ru.ysu.lms.web.session.SessionKeys
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
@RequestMapping("/admin")
|
@RequestMapping("/admin")
|
||||||
@@ -564,26 +570,19 @@ class AdminController(
|
|||||||
var i = 0
|
var i = 0
|
||||||
while (i < line.length) {
|
while (i < line.length) {
|
||||||
val c = line[i]
|
val c = line[i]
|
||||||
if (inQuotes) {
|
when {
|
||||||
if (c == '"') {
|
inQuotes && c == '"' && i + 1 < line.length && line[i + 1] == '"' -> {
|
||||||
if (i + 1 < line.length && line[i + 1] == '"') {
|
sb.append('"')
|
||||||
sb.append('"')
|
i++
|
||||||
i++
|
|
||||||
} else {
|
|
||||||
inQuotes = false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sb.append(c)
|
|
||||||
}
|
}
|
||||||
} else {
|
inQuotes && c == '"' -> inQuotes = false
|
||||||
when (c) {
|
inQuotes -> sb.append(c)
|
||||||
'"' -> inQuotes = true
|
c == '"' -> inQuotes = true
|
||||||
delimiter -> {
|
c == delimiter -> {
|
||||||
out.add(sb.toString())
|
out.add(sb.toString())
|
||||||
sb.setLength(0)
|
sb.setLength(0)
|
||||||
}
|
|
||||||
else -> sb.append(c)
|
|
||||||
}
|
}
|
||||||
|
else -> sb.append(c)
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
@@ -796,52 +795,62 @@ class AdminController(
|
|||||||
val group = api.groupDetail(t, id)
|
val group = api.groupDetail(t, id)
|
||||||
val studentById = group.students.associateBy { it.id }
|
val studentById = group.students.associateBy { it.id }
|
||||||
val labs = api.listLabs(t).filter { lab -> labId == null || lab.id == labId }
|
val labs = api.listLabs(t).filter { lab -> labId == null || lab.id == labId }
|
||||||
val out = java.io.ByteArrayOutputStream()
|
val bytes = buildGroupArchive(t, group.name, studentById, labs)
|
||||||
java.util.zip.ZipOutputStream(out).use { zos ->
|
return zipAttachment("solutions-${group.name}.zip", bytes)
|
||||||
java.util.zip.ZipEntry("ОПИСАНИЕ.txt").also {
|
}
|
||||||
it.time = 0
|
|
||||||
zos.putNextEntry(it)
|
private fun buildGroupArchive(
|
||||||
}
|
token: String,
|
||||||
zos.write("Архив сдач группы ${group.name} (последние попытки)\n".toByteArray(Charsets.UTF_8))
|
groupName: String,
|
||||||
|
studentById: Map<String, StudentRowDto>,
|
||||||
|
labs: List<LabSummaryDto>,
|
||||||
|
): ByteArray {
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
ZipOutputStream(out).use { zos ->
|
||||||
|
zos.putNextEntry(ZipEntry("ОПИСАНИЕ.txt").apply { time = 0 })
|
||||||
|
zos.write("Архив сдач группы $groupName (последние попытки)\n".toByteArray(Charsets.UTF_8))
|
||||||
zos.closeEntry()
|
zos.closeEntry()
|
||||||
for (lab in labs) {
|
for (lab in labs) {
|
||||||
val subs = api.labSubmissions(t, lab.id)
|
for (sub in api.labSubmissions(token, lab.id)) {
|
||||||
for (sub in subs) {
|
|
||||||
val student = studentById[sub.studentId] ?: continue
|
val student = studentById[sub.studentId] ?: continue
|
||||||
val bytes = api.downloadSubmissionAsAdmin(t, sub.id).bytes
|
val bytes = api.downloadSubmissionAsAdmin(token, sub.id).bytes
|
||||||
java.io.ByteArrayInputStream(bytes).let { src ->
|
appendSubmissionArchive(zos, lab, student, bytes)
|
||||||
java.util.zip.ZipInputStream(src).use { zin ->
|
|
||||||
var e = zin.nextEntry
|
|
||||||
while (e != null) {
|
|
||||||
if (!e.isDirectory) {
|
|
||||||
java.util.zip
|
|
||||||
.ZipEntry(
|
|
||||||
"ЛР${lab.number}/${student.studentNo}_${student.fullName.replace(
|
|
||||||
Regex("[\\\\/:*?\"<>|]"),
|
|
||||||
"_",
|
|
||||||
)}/${e.name}",
|
|
||||||
).let { ne ->
|
|
||||||
ne.time =
|
|
||||||
0
|
|
||||||
; zos.putNextEntry(ne)
|
|
||||||
}
|
|
||||||
zin.copyTo(zos)
|
|
||||||
zos.closeEntry()
|
|
||||||
}
|
|
||||||
e = zin.nextEntry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val name = "solutions-${group.name}.zip"
|
return out.toByteArray()
|
||||||
val encoded = java.net.URLEncoder.encode(name, java.nio.charset.StandardCharsets.UTF_8)
|
}
|
||||||
|
|
||||||
|
private fun appendSubmissionArchive(
|
||||||
|
zos: ZipOutputStream,
|
||||||
|
lab: LabSummaryDto,
|
||||||
|
student: StudentRowDto,
|
||||||
|
submissionZip: ByteArray,
|
||||||
|
) {
|
||||||
|
ZipInputStream(ByteArrayInputStream(submissionZip)).use { zin ->
|
||||||
|
var entry = zin.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
if (!entry.isDirectory) {
|
||||||
|
val safeName = student.fullName.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
||||||
|
zos.putNextEntry(ZipEntry("ЛР${lab.number}/${student.studentNo}_$safeName/${entry.name}").apply { time = 0 })
|
||||||
|
zin.copyTo(zos)
|
||||||
|
zos.closeEntry()
|
||||||
|
}
|
||||||
|
entry = zin.nextEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun zipAttachment(
|
||||||
|
name: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
): ResponseEntity<ByteArray> {
|
||||||
|
val encoded = URLEncoder.encode(name, StandardCharsets.UTF_8)
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.ok()
|
.ok()
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded")
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded")
|
||||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
.body(out.toByteArray())
|
.body(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/groups/{id}/publish")
|
@PostMapping("/groups/{id}/publish")
|
||||||
|
|||||||
Reference in New Issue
Block a user