Build: enable ktlint+detekt, format sources, add core unit tests
This commit is contained in:
@@ -8,16 +8,25 @@ import java.nio.file.Path
|
||||
import java.util.UUID
|
||||
|
||||
@Component
|
||||
class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") root: String) {
|
||||
class AssetStorage(
|
||||
@Value("\${lms.content.assets-dir:/data/content-assets}") root: String,
|
||||
) {
|
||||
private val rootDir: Path = Path.of(root)
|
||||
|
||||
init {
|
||||
Files.createDirectories(rootDir)
|
||||
}
|
||||
|
||||
data class StoredAsset(val publicName: String, val storedPath: Path, val mime: String)
|
||||
data class StoredAsset(
|
||||
val publicName: String,
|
||||
val storedPath: Path,
|
||||
val mime: String,
|
||||
)
|
||||
|
||||
fun store(originalName: String, bytes: ByteArray): StoredAsset {
|
||||
fun store(
|
||||
originalName: String,
|
||||
bytes: ByteArray,
|
||||
): StoredAsset {
|
||||
val ext = originalName.substringAfterLast('.', "").lowercase()
|
||||
val isVideo = ext in VIDEO_EXTENSIONS
|
||||
val isImage = ext in IMAGE_EXTENSIONS
|
||||
@@ -50,22 +59,23 @@ class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") roo
|
||||
return target to mimeFor(ext)
|
||||
}
|
||||
|
||||
private fun mimeFor(ext: String): String = when (ext) {
|
||||
"png" -> "image/png"
|
||||
"jpg", "jpeg" -> "image/jpeg"
|
||||
"gif" -> "image/gif"
|
||||
"svg" -> "image/svg+xml"
|
||||
"webp" -> "image/webp"
|
||||
"mp4" -> "video/mp4"
|
||||
"webm" -> "video/webm"
|
||||
"ogg", "ogv" -> "video/ogg"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
private fun mimeFor(ext: String): String =
|
||||
when (ext) {
|
||||
"png" -> "image/png"
|
||||
"jpg", "jpeg" -> "image/jpeg"
|
||||
"gif" -> "image/gif"
|
||||
"svg" -> "image/svg+xml"
|
||||
"webp" -> "image/webp"
|
||||
"mp4" -> "video/mp4"
|
||||
"webm" -> "video/webm"
|
||||
"ogg", "ogv" -> "video/ogg"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val IMAGE_EXTENSIONS = setOf("png", "jpg", "jpeg", "gif", "svg", "webp")
|
||||
val VIDEO_EXTENSIONS = setOf("mp4", "webm", "ogg", "ogv")
|
||||
const val MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10 МБ на картинку
|
||||
const val MAX_VIDEO_BYTES = 100 * 1024 * 1024 // 100 МБ на видео
|
||||
const val MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10 МБ на картинку
|
||||
const val MAX_VIDEO_BYTES = 100 * 1024 * 1024 // 100 МБ на видео
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,9 @@ data class ChapterTreeNode(
|
||||
val sections: List<SectionTreeNode>,
|
||||
)
|
||||
|
||||
data class ContentTree(val chapters: List<ChapterTreeNode>)
|
||||
data class ContentTree(
|
||||
val chapters: List<ChapterTreeNode>,
|
||||
)
|
||||
|
||||
data class ContentArticleView(
|
||||
val id: String,
|
||||
@@ -68,16 +70,33 @@ data class ContentImportResult(
|
||||
* (и перенос разделов между главами), порядок статей внутри разделов
|
||||
* (и перенос статей между разделами). Применяется одним запросом.
|
||||
*/
|
||||
data class ReorderArticle(val id: String)
|
||||
data class ReorderSection(val id: String, val articles: List<ReorderArticle> = emptyList())
|
||||
data class ReorderChapter(val id: String, val sections: List<ReorderSection> = emptyList())
|
||||
data class ReorderTree(val chapters: List<ReorderChapter> = emptyList())
|
||||
data class ReorderArticle(
|
||||
val id: String,
|
||||
)
|
||||
|
||||
data class ReorderSection(
|
||||
val id: String,
|
||||
val articles: List<ReorderArticle> = emptyList(),
|
||||
)
|
||||
|
||||
data class ReorderChapter(
|
||||
val id: String,
|
||||
val sections: List<ReorderSection> = emptyList(),
|
||||
)
|
||||
|
||||
data class ReorderTree(
|
||||
val chapters: List<ReorderChapter> = emptyList(),
|
||||
)
|
||||
|
||||
/** Порядок разделов внутри одной главы (drag & drop на странице главы). */
|
||||
data class ReorderSections(val sections: List<ReorderSection> = emptyList())
|
||||
data class ReorderSections(
|
||||
val sections: List<ReorderSection> = emptyList(),
|
||||
)
|
||||
|
||||
/** Порядок статей внутри одного раздела (drag & drop на странице раздела). */
|
||||
data class ReorderArticles(val articles: List<ReorderArticle> = emptyList())
|
||||
data class ReorderArticles(
|
||||
val articles: List<ReorderArticle> = emptyList(),
|
||||
)
|
||||
|
||||
private data class ParsedArticle(
|
||||
val path: String,
|
||||
@@ -98,16 +117,19 @@ class ContentService(
|
||||
private val articles: ContentArticleRepository,
|
||||
private val assets: AssetStorage,
|
||||
) {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun tree(includeHidden: Boolean = false): ContentTree {
|
||||
val chapterList = chapters.findAllByOrderByPositionAscIdAsc().filter { includeHidden || it.published }
|
||||
val sectionList = sections.findAllByOrderByPositionAscIdAsc()
|
||||
.filter { includeHidden || it.published }
|
||||
.groupBy { it.chapterId }
|
||||
val articleList = articles.findAllByOrderByPositionAscIdAsc()
|
||||
.filter { includeHidden || it.published }
|
||||
.groupBy { it.sectionId }
|
||||
val sectionList =
|
||||
sections
|
||||
.findAllByOrderByPositionAscIdAsc()
|
||||
.filter { includeHidden || it.published }
|
||||
.groupBy { it.chapterId }
|
||||
val articleList =
|
||||
articles
|
||||
.findAllByOrderByPositionAscIdAsc()
|
||||
.filter { includeHidden || it.published }
|
||||
.groupBy { it.sectionId }
|
||||
return ContentTree(
|
||||
chapterList.map { ch ->
|
||||
ChapterTreeNode(
|
||||
@@ -116,18 +138,20 @@ class ContentService(
|
||||
slug = ch.slug,
|
||||
position = ch.position,
|
||||
published = ch.published,
|
||||
sections = (sectionList[ch.id] ?: emptyList()).map { sec ->
|
||||
SectionTreeNode(
|
||||
id = sec.id.toString(),
|
||||
title = sec.title,
|
||||
slug = sec.slug,
|
||||
position = sec.position,
|
||||
published = sec.published,
|
||||
articles = (articleList[sec.id] ?: emptyList()).map { a ->
|
||||
ArticleTreeNode(a.id.toString(), a.title, a.slug, a.position, a.published)
|
||||
},
|
||||
)
|
||||
},
|
||||
sections =
|
||||
(sectionList[ch.id] ?: emptyList()).map { sec ->
|
||||
SectionTreeNode(
|
||||
id = sec.id.toString(),
|
||||
title = sec.title,
|
||||
slug = sec.slug,
|
||||
position = sec.position,
|
||||
published = sec.published,
|
||||
articles =
|
||||
(articleList[sec.id] ?: emptyList()).map { a ->
|
||||
ArticleTreeNode(a.id.toString(), a.title, a.slug, a.position, a.published)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -167,21 +191,24 @@ class ContentService(
|
||||
@Transactional
|
||||
fun reorder(tree: ReorderTree) {
|
||||
tree.chapters.forEachIndexed { chapterIndex, chapterDto ->
|
||||
val chapter = chapters.findById(uuidOrNull(chapterDto.id) ?: return@forEachIndexed).orElse(null)
|
||||
?: return@forEachIndexed
|
||||
val chapter =
|
||||
chapters.findById(uuidOrNull(chapterDto.id) ?: return@forEachIndexed).orElse(null)
|
||||
?: return@forEachIndexed
|
||||
chapter.position = chapterIndex + 1
|
||||
chapters.save(chapter)
|
||||
|
||||
chapterDto.sections.forEachIndexed { sectionIndex, sectionDto ->
|
||||
val section = sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null)
|
||||
?: return@forEachIndexed
|
||||
val section =
|
||||
sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null)
|
||||
?: return@forEachIndexed
|
||||
section.chapterId = chapter.id
|
||||
section.position = sectionIndex + 1
|
||||
sections.save(section)
|
||||
|
||||
sectionDto.articles.forEachIndexed { articleIndex, articleDto ->
|
||||
val article = articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
|
||||
?: return@forEachIndexed
|
||||
val article =
|
||||
articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
|
||||
?: return@forEachIndexed
|
||||
article.sectionId = section.id
|
||||
article.position = articleIndex + 1
|
||||
articles.save(article)
|
||||
@@ -192,17 +219,22 @@ class ContentService(
|
||||
|
||||
/** Порядок разделов внутри главы и статей внутри них (drag & drop на странице главы). */
|
||||
@Transactional
|
||||
fun reorderSections(chapterId: UUID, tree: List<ReorderSection>) {
|
||||
fun reorderSections(
|
||||
chapterId: UUID,
|
||||
tree: List<ReorderSection>,
|
||||
) {
|
||||
tree.forEachIndexed { sectionIndex, sectionDto ->
|
||||
val section = sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null)
|
||||
?: return@forEachIndexed
|
||||
val section =
|
||||
sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null)
|
||||
?: return@forEachIndexed
|
||||
if (section.chapterId != chapterId) return@forEachIndexed
|
||||
section.position = sectionIndex + 1
|
||||
sections.save(section)
|
||||
|
||||
sectionDto.articles.forEachIndexed { articleIndex, articleDto ->
|
||||
val article = articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
|
||||
?: return@forEachIndexed
|
||||
val article =
|
||||
articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
|
||||
?: return@forEachIndexed
|
||||
article.sectionId = section.id
|
||||
article.position = articleIndex + 1
|
||||
articles.save(article)
|
||||
@@ -212,35 +244,47 @@ class ContentService(
|
||||
|
||||
/** Порядок статей внутри одного раздела (drag & drop на странице раздела). */
|
||||
@Transactional
|
||||
fun reorderArticles(sectionId: UUID, tree: List<ReorderArticle>) {
|
||||
fun reorderArticles(
|
||||
sectionId: UUID,
|
||||
tree: List<ReorderArticle>,
|
||||
) {
|
||||
tree.forEachIndexed { articleIndex, articleDto ->
|
||||
val article = articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
|
||||
?: return@forEachIndexed
|
||||
val article =
|
||||
articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
|
||||
?: return@forEachIndexed
|
||||
if (article.sectionId != sectionId) return@forEachIndexed
|
||||
article.position = articleIndex + 1
|
||||
articles.save(article)
|
||||
}
|
||||
}
|
||||
|
||||
private fun uuidOrNull(value: String): UUID? =
|
||||
runCatching { UUID.fromString(value) }.getOrNull()
|
||||
private fun uuidOrNull(value: String): UUID? = runCatching { UUID.fromString(value) }.getOrNull()
|
||||
|
||||
@Transactional
|
||||
fun setChapterPublished(chapterId: UUID, published: Boolean) {
|
||||
fun setChapterPublished(
|
||||
chapterId: UUID,
|
||||
published: Boolean,
|
||||
) {
|
||||
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
ch.published = published
|
||||
chapters.save(ch)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun setSectionPublished(sectionId: UUID, published: Boolean) {
|
||||
fun setSectionPublished(
|
||||
sectionId: UUID,
|
||||
published: Boolean,
|
||||
) {
|
||||
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
sec.published = published
|
||||
sections.save(sec)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun setArticlePublished(articleId: UUID, published: Boolean) {
|
||||
fun setArticlePublished(
|
||||
articleId: UUID,
|
||||
published: Boolean,
|
||||
) {
|
||||
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
|
||||
a.published = published
|
||||
articles.save(a)
|
||||
@@ -267,7 +311,11 @@ class ContentService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateArticle(articleId: UUID, title: String, bodyMd: String) {
|
||||
fun updateArticle(
|
||||
articleId: UUID,
|
||||
title: String,
|
||||
bodyMd: String,
|
||||
) {
|
||||
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
|
||||
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
|
||||
a.title = title.trim()
|
||||
@@ -282,20 +330,31 @@ class ContentService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun createChapter(title: String, slug: String?): ChapterTreeNode {
|
||||
fun createChapter(
|
||||
title: String,
|
||||
slug: String?,
|
||||
): ChapterTreeNode {
|
||||
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
|
||||
val cleanSlug = slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
|
||||
?: uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
|
||||
val cleanSlug =
|
||||
slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
|
||||
?: uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
|
||||
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
|
||||
val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1))
|
||||
return ChapterTreeNode(
|
||||
id = ch.id.toString(), title = ch.title, slug = ch.slug,
|
||||
position = ch.position, published = ch.published, sections = emptyList(),
|
||||
id = ch.id.toString(),
|
||||
title = ch.title,
|
||||
slug = ch.slug,
|
||||
position = ch.position,
|
||||
published = ch.published,
|
||||
sections = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun renameChapter(chapterId: UUID, title: String) {
|
||||
fun renameChapter(
|
||||
chapterId: UUID,
|
||||
title: String,
|
||||
) {
|
||||
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
|
||||
ch.title = title.trim()
|
||||
@@ -309,24 +368,37 @@ class ContentService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun createSection(chapterId: UUID, title: String, slug: String?): SectionTreeNode {
|
||||
fun createSection(
|
||||
chapterId: UUID,
|
||||
title: String,
|
||||
slug: String?,
|
||||
): SectionTreeNode {
|
||||
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым")
|
||||
val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id)
|
||||
val cleanSlug = slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
|
||||
?: uniqueSlug(title, siblings.map { it.slug }.toSet())
|
||||
val cleanSlug =
|
||||
slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
|
||||
?: uniqueSlug(title, siblings.map { it.slug }.toSet())
|
||||
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
|
||||
val sec = sections.save(
|
||||
ContentSection(chapterId = ch.id, title = title.trim(), slug = cleanSlug, position = maxPos + 1),
|
||||
)
|
||||
val sec =
|
||||
sections.save(
|
||||
ContentSection(chapterId = ch.id, title = title.trim(), slug = cleanSlug, position = maxPos + 1),
|
||||
)
|
||||
return SectionTreeNode(
|
||||
id = sec.id.toString(), title = sec.title, slug = sec.slug,
|
||||
position = sec.position, published = sec.published, articles = emptyList(),
|
||||
id = sec.id.toString(),
|
||||
title = sec.title,
|
||||
slug = sec.slug,
|
||||
position = sec.position,
|
||||
published = sec.published,
|
||||
articles = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun renameSection(sectionId: UUID, title: String) {
|
||||
fun renameSection(
|
||||
sectionId: UUID,
|
||||
title: String,
|
||||
) {
|
||||
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
|
||||
sec.title = title.trim()
|
||||
@@ -341,7 +413,10 @@ class ContentService(
|
||||
|
||||
/** Переносит все статьи раздела в другой и удаляет раздел. */
|
||||
@Transactional
|
||||
fun moveArticlesAndDeleteSection(sectionId: UUID, targetSectionId: UUID) {
|
||||
fun moveArticlesAndDeleteSection(
|
||||
sectionId: UUID,
|
||||
targetSectionId: UUID,
|
||||
) {
|
||||
val src = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
val dst = sections.findById(targetSectionId).orElseThrow { NotFoundException("Раздел назначения не найден") }
|
||||
if (src.id == dst.id) throw BadRequestException("Раздел назначения совпадает с исходным")
|
||||
@@ -358,58 +433,88 @@ class ContentService(
|
||||
|
||||
/** Создаёт статью; если раздела нет — создаёт главу/раздел по названиям. */
|
||||
@Transactional
|
||||
fun createArticlePlaced(title: String, bodyMd: String, chapterId: UUID?, newChapterTitle: String?,
|
||||
sectionId: UUID?, newSectionTitle: String?): ContentArticleView {
|
||||
val targetSection: ContentSection = when {
|
||||
sectionId != null && !newSectionTitle.isNullOrBlank() -> {
|
||||
val anchor = sections.findById(sectionId)
|
||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||
val node = createSection(anchor.chapterId, newSectionTitle, null)
|
||||
sections.findById(UUID.fromString(node.id))
|
||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||
}
|
||||
sectionId != null -> sections.findById(sectionId)
|
||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||
!newSectionTitle.isNullOrBlank() -> {
|
||||
val chapter: ContentChapter = when {
|
||||
chapterId != null -> chapters.findById(chapterId)
|
||||
.orElseThrow { NotFoundException("Глава не найдена") }
|
||||
!newChapterTitle.isNullOrBlank() -> {
|
||||
val node = createChapter(newChapterTitle, null)
|
||||
chapters.findById(UUID.fromString(node.id))
|
||||
.orElseThrow { NotFoundException("Глава не найдена") }
|
||||
}
|
||||
else -> throw BadRequestException("Укажите главу")
|
||||
fun createArticlePlaced(
|
||||
title: String,
|
||||
bodyMd: String,
|
||||
chapterId: UUID?,
|
||||
newChapterTitle: String?,
|
||||
sectionId: UUID?,
|
||||
newSectionTitle: String?,
|
||||
): ContentArticleView {
|
||||
val targetSection: ContentSection =
|
||||
when {
|
||||
sectionId != null && !newSectionTitle.isNullOrBlank() -> {
|
||||
val anchor =
|
||||
sections
|
||||
.findById(sectionId)
|
||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||
val node = createSection(anchor.chapterId, newSectionTitle, null)
|
||||
sections
|
||||
.findById(UUID.fromString(node.id))
|
||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||
}
|
||||
val node = createSection(chapter.id, newSectionTitle, null)
|
||||
sections.findById(UUID.fromString(node.id))
|
||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||
sectionId != null ->
|
||||
sections
|
||||
.findById(sectionId)
|
||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||
!newSectionTitle.isNullOrBlank() -> {
|
||||
val chapter: ContentChapter =
|
||||
when {
|
||||
chapterId != null ->
|
||||
chapters
|
||||
.findById(chapterId)
|
||||
.orElseThrow { NotFoundException("Глава не найдена") }
|
||||
!newChapterTitle.isNullOrBlank() -> {
|
||||
val node = createChapter(newChapterTitle, null)
|
||||
chapters
|
||||
.findById(UUID.fromString(node.id))
|
||||
.orElseThrow { NotFoundException("Глава не найдена") }
|
||||
}
|
||||
else -> throw BadRequestException("Укажите главу")
|
||||
}
|
||||
val node = createSection(chapter.id, newSectionTitle, null)
|
||||
sections
|
||||
.findById(UUID.fromString(node.id))
|
||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||
}
|
||||
else -> throw BadRequestException("Укажите раздел")
|
||||
}
|
||||
else -> throw BadRequestException("Укажите раздел")
|
||||
}
|
||||
return createArticle(targetSection.id, title, bodyMd)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun createArticle(sectionId: UUID, title: String, bodyMd: String): ContentArticleView {
|
||||
fun createArticle(
|
||||
sectionId: UUID,
|
||||
title: String,
|
||||
bodyMd: String,
|
||||
): ContentArticleView {
|
||||
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
|
||||
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
|
||||
val slug = uniqueSlug(title, siblings.map { it.slug }.toSet())
|
||||
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
|
||||
val a = articles.save(
|
||||
ContentArticle(sectionId = sec.id, title = title.trim(), slug = slug, bodyMd = bodyMd, position = maxPos + 1),
|
||||
)
|
||||
val a =
|
||||
articles.save(
|
||||
ContentArticle(sectionId = sec.id, title = title.trim(), slug = slug, bodyMd = bodyMd, position = maxPos + 1),
|
||||
)
|
||||
return ContentArticleView(
|
||||
id = a.id.toString(), chapterSlug = ch.slug, chapterTitle = ch.title,
|
||||
sectionSlug = sec.slug, sectionTitle = sec.title,
|
||||
slug = a.slug, title = a.title, bodyMd = a.bodyMd,
|
||||
id = a.id.toString(),
|
||||
chapterSlug = ch.slug,
|
||||
chapterTitle = ch.title,
|
||||
sectionSlug = sec.slug,
|
||||
sectionTitle = sec.title,
|
||||
slug = a.slug,
|
||||
title = a.title,
|
||||
bodyMd = a.bodyMd,
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun moveArticle(articleId: UUID, newSectionId: UUID): ContentArticleView {
|
||||
fun moveArticle(
|
||||
articleId: UUID,
|
||||
newSectionId: UUID,
|
||||
): ContentArticleView {
|
||||
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
|
||||
val sec = sections.findById(newSectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
@@ -418,13 +523,21 @@ class ContentService(
|
||||
a.position = (siblings.maxOfOrNull { it.position } ?: 0) + 1
|
||||
articles.save(a)
|
||||
return ContentArticleView(
|
||||
id = a.id.toString(), chapterSlug = ch.slug, chapterTitle = ch.title,
|
||||
sectionSlug = sec.slug, sectionTitle = sec.title,
|
||||
slug = a.slug, title = a.title, bodyMd = a.bodyMd,
|
||||
id = a.id.toString(),
|
||||
chapterSlug = ch.slug,
|
||||
chapterTitle = ch.title,
|
||||
sectionSlug = sec.slug,
|
||||
sectionTitle = sec.title,
|
||||
slug = a.slug,
|
||||
title = a.title,
|
||||
bodyMd = a.bodyMd,
|
||||
)
|
||||
}
|
||||
|
||||
private fun uniqueSlug(title: String, taken: Set<String>): String {
|
||||
private fun uniqueSlug(
|
||||
title: String,
|
||||
taken: Set<String>,
|
||||
): String {
|
||||
val base = slugify(title) ?: "item"
|
||||
if (base !in taken) return base
|
||||
var i = 2
|
||||
@@ -438,7 +551,11 @@ class ContentService(
|
||||
* картинки в assets/.
|
||||
*/
|
||||
@Transactional
|
||||
fun importZip(filename: String, bytes: ByteArray, sync: Boolean = true): ContentImportResult {
|
||||
fun importZip(
|
||||
filename: String,
|
||||
bytes: ByteArray,
|
||||
sync: Boolean = true,
|
||||
): ContentImportResult {
|
||||
if (!filename.lowercase().endsWith(".zip")) {
|
||||
throw BadRequestException("Ожидается .zip архив")
|
||||
}
|
||||
@@ -446,9 +563,10 @@ class ContentService(
|
||||
if (files.isEmpty()) throw BadRequestException("Архив пуст")
|
||||
|
||||
val assetFiles = files.filterKeys { it.contains("/assets/") || it.startsWith("assets/") }
|
||||
val contentFiles = files.filterKeys { k ->
|
||||
(k.contains("/chapters/") || k.startsWith("chapters/")) && k.endsWith(".md")
|
||||
}
|
||||
val contentFiles =
|
||||
files.filterKeys { k ->
|
||||
(k.contains("/chapters/") || k.startsWith("chapters/")) && k.endsWith(".md")
|
||||
}
|
||||
if (contentFiles.isEmpty()) throw BadRequestException("В архиве нет каталога chapters/ с .md файлами")
|
||||
|
||||
val linkRewrite = mutableMapOf<String, String>()
|
||||
@@ -459,36 +577,38 @@ class ContentService(
|
||||
linkRewrite["assets/$name"] = "/api/v1/content/assets/${stored.publicName}"
|
||||
}
|
||||
|
||||
val parsed = contentFiles.map { (path, data) ->
|
||||
val clean = path.removePrefix("/").removePrefix("chapters/").removeSuffix(".md")
|
||||
val parts = clean.split('/')
|
||||
if (parts.size < 3) {
|
||||
throw BadRequestException("Неверная структура: $path (ожидается chapters/NN-глава/NN-раздел/NN-статья.md)")
|
||||
}
|
||||
val chapterDir = parts[0]
|
||||
val sectionDir = parts[1]
|
||||
val fileName = parts.drop(2).joinToString("-")
|
||||
val parsedContent = splitFrontMatter(String(data, Charsets.UTF_8))
|
||||
ParsedArticle(
|
||||
path = path,
|
||||
chapterPos = positionOf(chapterDir),
|
||||
chapterDir = titleFromDir(chapterDir),
|
||||
sectionPos = positionOf(sectionDir),
|
||||
sectionDir = titleFromDir(sectionDir),
|
||||
articlePos = positionOf(fileName),
|
||||
fileTitle = parsedContent.first["title"] ?: titleFromDir(fileName),
|
||||
fmSlug = parsedContent.first["slug"],
|
||||
body = parsedContent.second,
|
||||
)
|
||||
}.sortedWith(
|
||||
compareBy(
|
||||
{ it.chapterPos },
|
||||
{ it.chapterDir },
|
||||
{ it.sectionPos },
|
||||
{ it.sectionDir },
|
||||
{ it.articlePos },
|
||||
),
|
||||
)
|
||||
val parsed =
|
||||
contentFiles
|
||||
.map { (path, data) ->
|
||||
val clean = path.removePrefix("/").removePrefix("chapters/").removeSuffix(".md")
|
||||
val parts = clean.split('/')
|
||||
if (parts.size < 3) {
|
||||
throw BadRequestException("Неверная структура: $path (ожидается chapters/NN-глава/NN-раздел/NN-статья.md)")
|
||||
}
|
||||
val chapterDir = parts[0]
|
||||
val sectionDir = parts[1]
|
||||
val fileName = parts.drop(2).joinToString("-")
|
||||
val parsedContent = splitFrontMatter(String(data, Charsets.UTF_8))
|
||||
ParsedArticle(
|
||||
path = path,
|
||||
chapterPos = positionOf(chapterDir),
|
||||
chapterDir = titleFromDir(chapterDir),
|
||||
sectionPos = positionOf(sectionDir),
|
||||
sectionDir = titleFromDir(sectionDir),
|
||||
articlePos = positionOf(fileName),
|
||||
fileTitle = parsedContent.first["title"] ?: titleFromDir(fileName),
|
||||
fmSlug = parsedContent.first["slug"],
|
||||
body = parsedContent.second,
|
||||
)
|
||||
}.sortedWith(
|
||||
compareBy(
|
||||
{ it.chapterPos },
|
||||
{ it.chapterDir },
|
||||
{ it.sectionPos },
|
||||
{ it.sectionDir },
|
||||
{ it.articlePos },
|
||||
),
|
||||
)
|
||||
|
||||
var chCount = 0
|
||||
var secCount = 0
|
||||
@@ -523,15 +643,21 @@ class ContentService(
|
||||
val chapter = currentChapter ?: throw BadRequestException("Не удалось определить главу")
|
||||
if (currentSection?.title != p.sectionDir) {
|
||||
val slug = sectionSlug(p) ?: "section-${secCount + 1}"
|
||||
currentSection = (sections.findByChapterIdAndTitleIgnoreCase(chapter.id, p.sectionDir)
|
||||
?: sections.findByChapterIdAndSlug(chapter.id, slug))?.apply {
|
||||
currentSection = (
|
||||
sections.findByChapterIdAndTitleIgnoreCase(chapter.id, p.sectionDir)
|
||||
?: sections.findByChapterIdAndSlug(chapter.id, slug)
|
||||
)?.apply {
|
||||
title = p.sectionDir
|
||||
position = p.sectionPos
|
||||
} ?: sections.save(
|
||||
ContentSection(
|
||||
chapterId = chapter.id,
|
||||
title = p.sectionDir,
|
||||
slug = uniqueSlug(p.sectionDir, sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id).map { it.slug }.toSet()),
|
||||
slug =
|
||||
uniqueSlug(
|
||||
p.sectionDir,
|
||||
sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id).map { it.slug }.toSet(),
|
||||
),
|
||||
position = p.sectionPos,
|
||||
),
|
||||
)
|
||||
@@ -545,16 +671,19 @@ class ContentService(
|
||||
for ((from, to) in linkRewrite) {
|
||||
body = body.replace(from, to)
|
||||
}
|
||||
val slug = p.fmSlug?.takeIf { it.isNotBlank() }
|
||||
?: articleSlug(p)
|
||||
?: "article-${artCount + 1}"
|
||||
val existing = articles.findBySectionIdAndTitleIgnoreCase(section.id, p.fileTitle)
|
||||
?: articles.findBySectionIdAndSlug(section.id, slug)
|
||||
val article = (existing ?: ContentArticle(sectionId = section.id, slug = slug)).apply {
|
||||
title = p.fileTitle
|
||||
bodyMd = body
|
||||
position = p.articlePos
|
||||
}
|
||||
val slug =
|
||||
p.fmSlug?.takeIf { it.isNotBlank() }
|
||||
?: articleSlug(p)
|
||||
?: "article-${artCount + 1}"
|
||||
val existing =
|
||||
articles.findBySectionIdAndTitleIgnoreCase(section.id, p.fileTitle)
|
||||
?: articles.findBySectionIdAndSlug(section.id, slug)
|
||||
val article =
|
||||
(existing ?: ContentArticle(sectionId = section.id, slug = slug)).apply {
|
||||
title = p.fileTitle
|
||||
bodyMd = body
|
||||
position = p.articlePos
|
||||
}
|
||||
articles.save(article)
|
||||
seenArticles.add(article.id)
|
||||
artCount += 1
|
||||
@@ -613,19 +742,22 @@ class ContentService(
|
||||
private fun splitFrontMatter(content: String): Pair<Map<String, String>, String> {
|
||||
if (!content.startsWith("---")) return emptyMap<String, String>() to content
|
||||
val lines = content.lines()
|
||||
val end = lines.withIndex()
|
||||
.firstOrNull { it.index != 0 && it.value.trim() == "---" }
|
||||
?.index ?: return emptyMap<String, String>() to content
|
||||
val end =
|
||||
lines
|
||||
.withIndex()
|
||||
.firstOrNull { it.index != 0 && it.value.trim() == "---" }
|
||||
?.index ?: return emptyMap<String, String>() to content
|
||||
val fm = mutableMapOf<String, String>()
|
||||
for (line in lines.subList(1, end)) {
|
||||
val parts = line.split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val v = parts[1].trim()
|
||||
val unquoted = if (v.length >= 2 && v.first() == '"' && v.last() == '"') {
|
||||
v.substring(1, v.length - 1)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
val unquoted =
|
||||
if (v.length >= 2 && v.first() == '"' && v.last() == '"') {
|
||||
v.substring(1, v.length - 1)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
fm[parts[0].trim()] = unquoted
|
||||
}
|
||||
}
|
||||
@@ -638,29 +770,61 @@ class ContentService(
|
||||
}
|
||||
|
||||
private fun titleFromDir(dir: String): String {
|
||||
val raw = dir.substringAfterLast('/')
|
||||
.replace(Regex("^\\d{1,3}[-_]"), "")
|
||||
.replace('_', ' ')
|
||||
val raw =
|
||||
dir
|
||||
.substringAfterLast('/')
|
||||
.replace(Regex("^\\d{1,3}[-_]"), "")
|
||||
.replace('_', ' ')
|
||||
return raw.ifBlank { dir }
|
||||
}
|
||||
|
||||
private fun slugify(value: String): String? {
|
||||
val translit = TRANSLIT.entries.fold(value.lowercase()) { acc, (from, to) -> acc.replace(from, to) }
|
||||
val slug = translit
|
||||
.replace(Regex("\\s+"), "-")
|
||||
.replace(Regex("[^a-z0-9-]"), "")
|
||||
.replace(Regex("-+"), "-")
|
||||
.trim('-')
|
||||
val slug =
|
||||
translit
|
||||
.replace(Regex("\\s+"), "-")
|
||||
.replace(Regex("[^a-z0-9-]"), "")
|
||||
.replace(Regex("-+"), "-")
|
||||
.trim('-')
|
||||
return slug.ifBlank { null }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val TRANSLIT = mapOf(
|
||||
"а" to "a", "б" to "b", "в" to "v", "г" to "g", "д" to "d", "е" to "e", "ё" to "e",
|
||||
"ж" to "zh", "з" to "z", "и" to "i", "й" to "y", "к" to "k", "л" to "l", "м" to "m",
|
||||
"н" to "n", "о" to "o", "п" to "p", "р" to "r", "с" to "s", "т" to "t", "у" to "u",
|
||||
"ф" to "f", "х" to "h", "ц" to "c", "ч" to "ch", "ш" to "sh", "щ" to "sch", "ъ" to "",
|
||||
"ы" to "y", "ь" to "", "э" to "e", "ю" to "yu", "я" to "ya",
|
||||
)
|
||||
val TRANSLIT =
|
||||
mapOf(
|
||||
"а" to "a",
|
||||
"б" to "b",
|
||||
"в" to "v",
|
||||
"г" to "g",
|
||||
"д" to "d",
|
||||
"е" to "e",
|
||||
"ё" to "e",
|
||||
"ж" to "zh",
|
||||
"з" to "z",
|
||||
"и" to "i",
|
||||
"й" to "y",
|
||||
"к" to "k",
|
||||
"л" to "l",
|
||||
"м" to "m",
|
||||
"н" to "n",
|
||||
"о" to "o",
|
||||
"п" to "p",
|
||||
"р" to "r",
|
||||
"с" to "s",
|
||||
"т" to "t",
|
||||
"у" to "u",
|
||||
"ф" to "f",
|
||||
"х" to "h",
|
||||
"ц" to "c",
|
||||
"ч" to "ch",
|
||||
"ш" to "sh",
|
||||
"щ" to "sch",
|
||||
"ъ" to "",
|
||||
"ы" to "y",
|
||||
"ь" to "",
|
||||
"э" to "e",
|
||||
"ю" to "yu",
|
||||
"я" to "ya",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,14 @@ import java.util.UUID
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/content")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
class ContentAdminController(private val contentService: ContentService) {
|
||||
class ContentAdminController(
|
||||
private val contentService: ContentService,
|
||||
) {
|
||||
data class ArticleUpdatePayload(
|
||||
val title: String,
|
||||
val bodyMd: String,
|
||||
)
|
||||
|
||||
data class ArticleUpdatePayload(val title: String, val bodyMd: String)
|
||||
data class ArticleCreatePayload(
|
||||
val chapterId: String? = null,
|
||||
val sectionId: String? = null,
|
||||
@@ -34,11 +39,28 @@ class ContentAdminController(private val contentService: ContentService) {
|
||||
val title: String,
|
||||
val bodyMd: String = "",
|
||||
)
|
||||
data class ArticleMovePayload(val sectionId: String)
|
||||
data class TitlePayload(val title: String)
|
||||
data class PublishedPayload(val published: Boolean)
|
||||
data class ChapterCreatePayload(val title: String, val slug: String? = null)
|
||||
data class SectionCreatePayload(val title: String, val slug: String? = null)
|
||||
|
||||
data class ArticleMovePayload(
|
||||
val sectionId: String,
|
||||
)
|
||||
|
||||
data class TitlePayload(
|
||||
val title: String,
|
||||
)
|
||||
|
||||
data class PublishedPayload(
|
||||
val published: Boolean,
|
||||
)
|
||||
|
||||
data class ChapterCreatePayload(
|
||||
val title: String,
|
||||
val slug: String? = null,
|
||||
)
|
||||
|
||||
data class SectionCreatePayload(
|
||||
val title: String,
|
||||
val slug: String? = null,
|
||||
)
|
||||
|
||||
/** Полное дерево материалов, включая скрытые (для админки). */
|
||||
@GetMapping("/tree/all")
|
||||
@@ -53,42 +75,65 @@ class ContentAdminController(private val contentService: ContentService) {
|
||||
): ContentPublicController.ContentArticleViewDto {
|
||||
val a = contentService.findArticle(chapter, section, article, includeHidden = true)
|
||||
return ContentPublicController.ContentArticleViewDto(
|
||||
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle,
|
||||
sectionSlug = a.sectionSlug, sectionTitle = a.sectionTitle,
|
||||
slug = a.slug, title = a.title, bodyMd = a.bodyMd, published = a.published,
|
||||
id = a.id,
|
||||
chapterSlug = a.chapterSlug,
|
||||
chapterTitle = a.chapterTitle,
|
||||
sectionSlug = a.sectionSlug,
|
||||
sectionTitle = a.sectionTitle,
|
||||
slug = a.slug,
|
||||
title = a.title,
|
||||
bodyMd = a.bodyMd,
|
||||
published = a.published,
|
||||
)
|
||||
}
|
||||
|
||||
@PutMapping("/chapters/{id}/published")
|
||||
fun chapterPublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
|
||||
fun chapterPublished(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: PublishedPayload,
|
||||
) {
|
||||
contentService.setChapterPublished(id, body.published)
|
||||
}
|
||||
|
||||
@PutMapping("/sections/{id}/published")
|
||||
fun sectionPublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
|
||||
fun sectionPublished(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: PublishedPayload,
|
||||
) {
|
||||
contentService.setSectionPublished(id, body.published)
|
||||
}
|
||||
|
||||
@PutMapping("/articles/{id}/published")
|
||||
fun articlePublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
|
||||
fun articlePublished(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: PublishedPayload,
|
||||
) {
|
||||
contentService.setArticlePublished(id, body.published)
|
||||
}
|
||||
|
||||
/** Новое расположение материалов (drag & drop глав, разделов и статей). */
|
||||
@PostMapping("/reorder")
|
||||
fun reorder(@RequestBody body: ReorderTree) {
|
||||
fun reorder(
|
||||
@RequestBody body: ReorderTree,
|
||||
) {
|
||||
contentService.reorder(body)
|
||||
}
|
||||
|
||||
/** Порядок разделов внутри главы (drag & drop на странице главы). */
|
||||
@PostMapping("/chapters/{id}/sections/reorder")
|
||||
fun reorderSections(@PathVariable id: UUID, @RequestBody body: ReorderSections) {
|
||||
fun reorderSections(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: ReorderSections,
|
||||
) {
|
||||
contentService.reorderSections(id, body.sections)
|
||||
}
|
||||
|
||||
/** Порядок статей внутри раздела (drag & drop на странице раздела). */
|
||||
@PostMapping("/sections/{id}/articles/reorder")
|
||||
fun reorderArticles(@PathVariable id: UUID, @RequestBody body: ReorderArticles) {
|
||||
fun reorderArticles(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: ReorderArticles,
|
||||
) {
|
||||
contentService.reorderArticles(id, body.articles)
|
||||
}
|
||||
|
||||
@@ -105,25 +150,35 @@ class ContentAdminController(private val contentService: ContentService) {
|
||||
fun list() = contentService.listAll()
|
||||
|
||||
@PostMapping("/chapters")
|
||||
fun createChapter(@RequestBody body: ChapterCreatePayload) = contentService.createChapter(body.title, body.slug)
|
||||
fun createChapter(
|
||||
@RequestBody body: ChapterCreatePayload,
|
||||
) = contentService.createChapter(body.title, body.slug)
|
||||
|
||||
@PutMapping("/chapters/{id}")
|
||||
fun renameChapter(@PathVariable id: UUID, @RequestBody body: TitlePayload) =
|
||||
contentService.renameChapter(id, body.title)
|
||||
fun renameChapter(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: TitlePayload,
|
||||
) = contentService.renameChapter(id, body.title)
|
||||
|
||||
@DeleteMapping("/chapters/{id}")
|
||||
fun deleteChapter(@PathVariable id: UUID): ResponseEntity<Void> {
|
||||
fun deleteChapter(
|
||||
@PathVariable id: UUID,
|
||||
): ResponseEntity<Void> {
|
||||
contentService.deleteChapter(id)
|
||||
return ResponseEntity.noContent().build()
|
||||
}
|
||||
|
||||
@PostMapping("/sections")
|
||||
fun createSection(@RequestParam chapterId: UUID, @RequestBody body: SectionCreatePayload) =
|
||||
contentService.createSection(chapterId, body.title, body.slug)
|
||||
fun createSection(
|
||||
@RequestParam chapterId: UUID,
|
||||
@RequestBody body: SectionCreatePayload,
|
||||
) = contentService.createSection(chapterId, body.title, body.slug)
|
||||
|
||||
@PutMapping("/sections/{id}")
|
||||
fun renameSection(@PathVariable id: UUID, @RequestBody body: TitlePayload) =
|
||||
contentService.renameSection(id, body.title)
|
||||
fun renameSection(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: TitlePayload,
|
||||
) = contentService.renameSection(id, body.title)
|
||||
|
||||
@DeleteMapping("/sections/{id}")
|
||||
fun deleteSection(
|
||||
@@ -139,7 +194,9 @@ class ContentAdminController(private val contentService: ContentService) {
|
||||
}
|
||||
|
||||
@PostMapping("/articles")
|
||||
fun createArticle(@RequestBody body: ArticleCreatePayload) = contentService.createArticlePlaced(
|
||||
fun createArticle(
|
||||
@RequestBody body: ArticleCreatePayload,
|
||||
) = contentService.createArticlePlaced(
|
||||
title = body.title,
|
||||
bodyMd = body.bodyMd,
|
||||
chapterId = body.chapterId?.let { UUID.fromString(it) },
|
||||
@@ -149,16 +206,23 @@ class ContentAdminController(private val contentService: ContentService) {
|
||||
)
|
||||
|
||||
@PutMapping("/articles/{id}")
|
||||
fun update(@PathVariable id: UUID, @RequestBody body: ArticleUpdatePayload) {
|
||||
fun update(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: ArticleUpdatePayload,
|
||||
) {
|
||||
contentService.updateArticle(id, body.title, body.bodyMd)
|
||||
}
|
||||
|
||||
@PostMapping("/articles/{id}/move")
|
||||
fun move(@PathVariable id: UUID, @RequestBody body: ArticleMovePayload) =
|
||||
contentService.moveArticle(id, UUID.fromString(body.sectionId))
|
||||
fun move(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: ArticleMovePayload,
|
||||
) = contentService.moveArticle(id, UUID.fromString(body.sectionId))
|
||||
|
||||
@DeleteMapping("/articles/{id}")
|
||||
fun delete(@PathVariable id: UUID): ResponseEntity<Void> {
|
||||
fun delete(
|
||||
@PathVariable id: UUID,
|
||||
): ResponseEntity<Void> {
|
||||
contentService.deleteArticle(id)
|
||||
return ResponseEntity.noContent().build()
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import ru.ysu.lms.academic.content.AssetStorage
|
||||
import ru.ysu.lms.academic.content.ContentService
|
||||
import java.nio.file.Files
|
||||
|
||||
/** Публичное чтение учебных материалов — доступно без аутентификации. */
|
||||
@RestController
|
||||
@@ -18,7 +17,6 @@ class ContentPublicController(
|
||||
private val contentService: ContentService,
|
||||
private val assetStorage: AssetStorage,
|
||||
) {
|
||||
|
||||
@GetMapping("/tree")
|
||||
fun tree() = contentService.tree()
|
||||
|
||||
@@ -30,9 +28,15 @@ class ContentPublicController(
|
||||
): ContentArticleViewDto {
|
||||
val a = contentService.findArticle(chapter, section, article)
|
||||
return ContentArticleViewDto(
|
||||
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle,
|
||||
sectionSlug = a.sectionSlug, sectionTitle = a.sectionTitle,
|
||||
slug = a.slug, title = a.title, bodyMd = a.bodyMd, published = a.published,
|
||||
id = a.id,
|
||||
chapterSlug = a.chapterSlug,
|
||||
chapterTitle = a.chapterTitle,
|
||||
sectionSlug = a.sectionSlug,
|
||||
sectionTitle = a.sectionTitle,
|
||||
slug = a.slug,
|
||||
title = a.title,
|
||||
bodyMd = a.bodyMd,
|
||||
published = a.published,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,13 +53,19 @@ class ContentPublicController(
|
||||
)
|
||||
|
||||
@GetMapping("/assets/{name}")
|
||||
fun asset(@PathVariable name: String): ResponseEntity<FileSystemResource> {
|
||||
val (path, mime) = assetStorage.read(name)
|
||||
?: return ResponseEntity.notFound().build()
|
||||
fun asset(
|
||||
@PathVariable name: String,
|
||||
): ResponseEntity<FileSystemResource> {
|
||||
val (path, mime) =
|
||||
assetStorage.read(name)
|
||||
?: return ResponseEntity.notFound().build()
|
||||
val media = runCatching { MediaType.parseMediaType(mime) }.getOrElse { MediaType.APPLICATION_OCTET_STREAM }
|
||||
return ResponseEntity.ok()
|
||||
return ResponseEntity
|
||||
.ok()
|
||||
.contentType(media)
|
||||
.cacheControl(org.springframework.http.CacheControl.maxAge(java.time.Duration.ofDays(7)))
|
||||
.body(FileSystemResource(path))
|
||||
.cacheControl(
|
||||
org.springframework.http.CacheControl
|
||||
.maxAge(java.time.Duration.ofDays(7)),
|
||||
).body(FileSystemResource(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,72 @@
|
||||
package ru.ysu.lms.academic.controller
|
||||
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import jakarta.validation.Valid
|
||||
import ru.ysu.lms.academic.lab.*
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import ru.ysu.lms.academic.lab.LabCreateRequest
|
||||
import ru.ysu.lms.academic.lab.LabDetail
|
||||
import ru.ysu.lms.academic.lab.LabImportResult
|
||||
import ru.ysu.lms.academic.lab.LabService
|
||||
import ru.ysu.lms.academic.lab.LabSummary
|
||||
import ru.ysu.lms.academic.lab.LabUpdateRequest
|
||||
import ru.ysu.lms.academic.lab.LevelRenameRequest
|
||||
import ru.ysu.lms.academic.lab.LevelSaveRequest
|
||||
import ru.ysu.lms.academic.lab.VariantSaveRequest
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/labs")
|
||||
class LabController(private val labService: LabService) {
|
||||
|
||||
class LabController(
|
||||
private val labService: LabService,
|
||||
) {
|
||||
@GetMapping
|
||||
fun list(): List<LabSummary> = labService.list()
|
||||
|
||||
@GetMapping("/{id}")
|
||||
fun detail(@PathVariable id: UUID): LabDetail = labService.detail(id)
|
||||
fun detail(
|
||||
@PathVariable id: UUID,
|
||||
): LabDetail = labService.detail(id)
|
||||
|
||||
@PostMapping("/import")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun importLab(@RequestParam("file") file: MultipartFile): LabImportResult {
|
||||
fun importLab(
|
||||
@RequestParam("file") file: MultipartFile,
|
||||
): LabImportResult {
|
||||
if (file.isEmpty) throw BadRequestException("Выберите файл")
|
||||
return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes)
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun delete(@PathVariable id: UUID) {
|
||||
fun delete(
|
||||
@PathVariable id: UUID,
|
||||
) {
|
||||
labService.delete(id)
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun update(@PathVariable id: UUID, @Valid @RequestBody body: LabUpdateRequest): LabDetail =
|
||||
labService.update(id, body)
|
||||
fun update(
|
||||
@PathVariable id: UUID,
|
||||
@Valid @RequestBody body: LabUpdateRequest,
|
||||
): LabDetail = labService.update(id, body)
|
||||
|
||||
@PostMapping("/{id}/variants")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun addVariant(@PathVariable id: UUID, @Valid @RequestBody body: VariantSaveRequest): LabDetail =
|
||||
labService.addVariant(id, body)
|
||||
fun addVariant(
|
||||
@PathVariable id: UUID,
|
||||
@Valid @RequestBody body: VariantSaveRequest,
|
||||
): LabDetail = labService.addVariant(id, body)
|
||||
|
||||
@PutMapping("/{id}/variants/{variantId}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@@ -52,22 +78,31 @@ class LabController(private val labService: LabService) {
|
||||
|
||||
@DeleteMapping("/{id}/variants/{variantId}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun deleteVariant(@PathVariable id: UUID, @PathVariable variantId: UUID): LabDetail =
|
||||
labService.deleteVariant(id, variantId)
|
||||
fun deleteVariant(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable variantId: UUID,
|
||||
): LabDetail = labService.deleteVariant(id, variantId)
|
||||
|
||||
@PostMapping("/{id}/toggle-status")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun toggleStatus(@PathVariable id: UUID) {
|
||||
fun toggleStatus(
|
||||
@PathVariable id: UUID,
|
||||
) {
|
||||
labService.toggleStatus(id)
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun create(@Valid @RequestBody body: LabCreateRequest): LabDetail = labService.create(body)
|
||||
fun create(
|
||||
@Valid @RequestBody body: LabCreateRequest,
|
||||
): LabDetail = labService.create(body)
|
||||
|
||||
@PostMapping("/{id}/levels")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun addLevel(@PathVariable id: UUID, @Valid @RequestBody body: LevelSaveRequest): LabDetail {
|
||||
fun addLevel(
|
||||
@PathVariable id: UUID,
|
||||
@Valid @RequestBody body: LevelSaveRequest,
|
||||
): LabDetail {
|
||||
labService.addLevel(id, body)
|
||||
return labService.detail(id)
|
||||
}
|
||||
@@ -82,6 +117,8 @@ class LabController(private val labService: LabService) {
|
||||
|
||||
@DeleteMapping("/{id}/levels/{levelId}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun deleteLevel(@PathVariable id: UUID, @PathVariable levelId: UUID): LabDetail =
|
||||
labService.deleteLevel(id, levelId)
|
||||
}
|
||||
fun deleteLevel(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable levelId: UUID,
|
||||
): LabDetail = labService.deleteLevel(id, levelId)
|
||||
}
|
||||
|
||||
@@ -38,4 +38,4 @@ class ContentArticle(
|
||||
@Column(nullable = false) var bodyMd: String = "",
|
||||
@Column(nullable = false) var position: Int = 0,
|
||||
@Column(nullable = false) var published: Boolean = true,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -6,8 +6,6 @@ import jakarta.persistence.FetchType
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.JoinColumn
|
||||
import jakarta.persistence.ManyToOne
|
||||
import jakarta.persistence.OneToMany
|
||||
import jakarta.persistence.OrderBy
|
||||
import jakarta.persistence.Table
|
||||
import ru.ysu.lms.common.Ids
|
||||
import java.time.Instant
|
||||
@@ -37,4 +35,4 @@ class Lab(
|
||||
var status: String = "draft",
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: Instant = Instant.now(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -28,4 +28,4 @@ class LabLevel(
|
||||
var position: Int = 0,
|
||||
@Column(name = "points_max", columnDefinition = "numeric(4,1)")
|
||||
var pointsMax: java.math.BigDecimal? = null,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -18,4 +18,4 @@ class Semester(
|
||||
var year: Int = 0,
|
||||
@Column(nullable = false)
|
||||
var active: Boolean = false,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -29,4 +29,4 @@ class Variant(
|
||||
var statementMd: String = "",
|
||||
@Column(nullable = false)
|
||||
var position: Int = 0,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -119,4 +119,4 @@ data class LabCreateRequest(
|
||||
data class LevelRenameRequest(
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -16,12 +16,16 @@ import java.util.zip.ZipInputStream
|
||||
*/
|
||||
@Component
|
||||
class LabImportParser {
|
||||
private val yaml = ObjectMapper(YAMLFactory())
|
||||
.registerModule(KotlinModule.Builder().build())
|
||||
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
|
||||
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
private val yaml =
|
||||
ObjectMapper(YAMLFactory())
|
||||
.registerModule(KotlinModule.Builder().build())
|
||||
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
|
||||
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
|
||||
fun parse(filename: String, bytes: ByteArray): LabImportBundle {
|
||||
fun parse(
|
||||
filename: String,
|
||||
bytes: ByteArray,
|
||||
): LabImportBundle {
|
||||
if (!filename.lowercase().endsWith(".zip")) {
|
||||
throw BadRequestException("Формат импорта — .zip (lab.yaml + README.md + variants/)")
|
||||
}
|
||||
@@ -40,8 +44,9 @@ class LabImportParser {
|
||||
}
|
||||
}
|
||||
|
||||
val metaEntry = entries.entries.firstOrNull { it.key.endsWith("lab.yaml") || it.key.endsWith("lab.yml") }
|
||||
?: throw BadRequestException("В архиве не найден lab.yaml")
|
||||
val metaEntry =
|
||||
entries.entries.firstOrNull { it.key.endsWith("lab.yaml") || it.key.endsWith("lab.yml") }
|
||||
?: throw BadRequestException("В архиве не найден lab.yaml")
|
||||
val meta = yaml.readValue(metaEntry.value, LabMeta::class.java)
|
||||
|
||||
val readmeEntry = entries.entries.firstOrNull { it.key.endsWith("/README.md") || it.key == "README.md" }
|
||||
@@ -71,7 +76,11 @@ class LabImportParser {
|
||||
return LabImportBundle(meta, readmeMd, variants, assets)
|
||||
}
|
||||
|
||||
private fun parseVariantFile(path: String, content: String, levels: List<LabLevelMeta>): VariantMeta? {
|
||||
private fun parseVariantFile(
|
||||
path: String,
|
||||
content: String,
|
||||
levels: List<LabLevelMeta>,
|
||||
): VariantMeta? {
|
||||
val levelMap = levels.associateBy { it.code }
|
||||
val lines = content.lines()
|
||||
val firstSep = lines.indexOfFirst { it.trim() == "---" }
|
||||
@@ -111,9 +120,10 @@ class LabImportParser {
|
||||
value
|
||||
}
|
||||
|
||||
private fun normalizeLevel(s: String): String = when (s.lowercase().trim()) {
|
||||
"main", "basic", "обычная", "основная" -> "basic"
|
||||
"advanced", "повышенная", "повышенной", "hard" -> "advanced"
|
||||
else -> s.lowercase().trim()
|
||||
}
|
||||
}
|
||||
private fun normalizeLevel(s: String): String =
|
||||
when (s.lowercase().trim()) {
|
||||
"main", "basic", "обычная", "основная" -> "basic"
|
||||
"advanced", "повышенная", "повышенной", "hard" -> "advanced"
|
||||
else -> s.lowercase().trim()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ import ru.ysu.lms.academic.repo.LabLevelRepository
|
||||
import ru.ysu.lms.academic.repo.LabRepository
|
||||
import ru.ysu.lms.academic.repo.SemesterRepository
|
||||
import ru.ysu.lms.academic.repo.VariantRepository
|
||||
import ru.ysu.lms.common.NotFoundException
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.common.ConflictException
|
||||
import ru.ysu.lms.common.NotFoundException
|
||||
import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
@@ -28,12 +28,15 @@ class LabService(
|
||||
private val semesters: SemesterRepository,
|
||||
private val parser: LabImportParser,
|
||||
) {
|
||||
|
||||
@Transactional
|
||||
fun importFromFile(filename: String, bytes: ByteArray): LabImportResult {
|
||||
fun importFromFile(
|
||||
filename: String,
|
||||
bytes: ByteArray,
|
||||
): LabImportResult {
|
||||
val bundle = parser.parse(filename, bytes)
|
||||
val semester = semesters.findFirstByActiveTrue()
|
||||
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
|
||||
val semester =
|
||||
semesters.findFirstByActiveTrue()
|
||||
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
|
||||
|
||||
if (labs.existsByNumberAndSemesterId(bundle.meta.number, semester.id)) {
|
||||
throw ConflictException(
|
||||
@@ -41,46 +44,56 @@ class LabService(
|
||||
)
|
||||
}
|
||||
|
||||
val lab = labs.save(Lab(
|
||||
number = bundle.meta.number,
|
||||
title = bundle.meta.title,
|
||||
semester = semester,
|
||||
descriptionMd = bundle.descriptionMd,
|
||||
pointsMax = bundle.meta.pointsMax,
|
||||
status = bundle.meta.status,
|
||||
deadlineAt = parseInstant(bundle.meta.deadlineAt),
|
||||
defenseAt = parseInstant(bundle.meta.defenseAt),
|
||||
))
|
||||
|
||||
val levelMetas = bundle.meta.levels.ifEmpty {
|
||||
listOf(
|
||||
LabLevelMeta("basic", "Основная группа", position = 1),
|
||||
LabLevelMeta("advanced", "Повышенная сложность", position = 2),
|
||||
val lab =
|
||||
labs.save(
|
||||
Lab(
|
||||
number = bundle.meta.number,
|
||||
title = bundle.meta.title,
|
||||
semester = semester,
|
||||
descriptionMd = bundle.descriptionMd,
|
||||
pointsMax = bundle.meta.pointsMax,
|
||||
status = bundle.meta.status,
|
||||
deadlineAt = parseInstant(bundle.meta.deadlineAt),
|
||||
defenseAt = parseInstant(bundle.meta.defenseAt),
|
||||
),
|
||||
)
|
||||
}
|
||||
val levelEntities = levelMetas.map { meta ->
|
||||
labLevels.save(LabLevel(
|
||||
lab = lab,
|
||||
code = meta.code,
|
||||
title = meta.title,
|
||||
description = meta.description.orEmpty(),
|
||||
position = meta.position ?: 0,
|
||||
pointsMax = meta.pointsMax,
|
||||
))
|
||||
}
|
||||
|
||||
val levelMetas =
|
||||
bundle.meta.levels.ifEmpty {
|
||||
listOf(
|
||||
LabLevelMeta("basic", "Основная группа", position = 1),
|
||||
LabLevelMeta("advanced", "Повышенная сложность", position = 2),
|
||||
)
|
||||
}
|
||||
val levelEntities =
|
||||
levelMetas.map { meta ->
|
||||
labLevels.save(
|
||||
LabLevel(
|
||||
lab = lab,
|
||||
code = meta.code,
|
||||
title = meta.title,
|
||||
description = meta.description.orEmpty(),
|
||||
position = meta.position ?: 0,
|
||||
pointsMax = meta.pointsMax,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val levelByCode = levelEntities.associateBy { it.code }
|
||||
|
||||
bundle.variants.forEach { vmeta ->
|
||||
val level = levelByCode[vmeta.level]
|
||||
?: throw BadRequestException("Неизвестный уровень ${vmeta.level} у варианта ${vmeta.number}")
|
||||
variantsRepo.save(Variant(
|
||||
lab = lab,
|
||||
labLevel = level,
|
||||
number = vmeta.number,
|
||||
title = vmeta.title,
|
||||
statementMd = vmeta.statement,
|
||||
))
|
||||
val level =
|
||||
levelByCode[vmeta.level]
|
||||
?: throw BadRequestException("Неизвестный уровень ${vmeta.level} у варианта ${vmeta.number}")
|
||||
variantsRepo.save(
|
||||
Variant(
|
||||
lab = lab,
|
||||
labLevel = level,
|
||||
number = vmeta.number,
|
||||
title = vmeta.title,
|
||||
statementMd = vmeta.statement,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return LabImportResult(
|
||||
@@ -122,25 +135,29 @@ class LabService(
|
||||
deadlineAt = lab.deadlineAt?.toString(),
|
||||
defenseAt = lab.defenseAt?.toString(),
|
||||
pointsMax = lab.pointsMax,
|
||||
levels = levels.map { level ->
|
||||
// Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой).
|
||||
val vs = variantsRepo.findByLabIdOrderByNumber(lab.id)
|
||||
.filter { it.labLevel?.id == level.id }
|
||||
.sortedWith(compareBy({ naturalNumber(it.number) }, { it.number }))
|
||||
LabLevelDetail(
|
||||
id = level.id.toString(),
|
||||
code = level.code,
|
||||
title = level.title,
|
||||
variants = vs.map { v ->
|
||||
VariantDetail(
|
||||
id = v.id.toString(),
|
||||
number = v.number,
|
||||
title = v.title,
|
||||
statementMd = v.statementMd,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
levels =
|
||||
levels.map { level ->
|
||||
// Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой).
|
||||
val vs =
|
||||
variantsRepo
|
||||
.findByLabIdOrderByNumber(lab.id)
|
||||
.filter { it.labLevel?.id == level.id }
|
||||
.sortedWith(compareBy({ naturalNumber(it.number) }, { it.number }))
|
||||
LabLevelDetail(
|
||||
id = level.id.toString(),
|
||||
code = level.code,
|
||||
title = level.title,
|
||||
variants =
|
||||
vs.map { v ->
|
||||
VariantDetail(
|
||||
id = v.id.toString(),
|
||||
number = v.number,
|
||||
title = v.title,
|
||||
statementMd = v.statementMd,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -153,7 +170,10 @@ class LabService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun update(labId: UUID, request: LabUpdateRequest): LabDetail {
|
||||
fun update(
|
||||
labId: UUID,
|
||||
request: LabUpdateRequest,
|
||||
): LabDetail {
|
||||
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
||||
if (request.title.isBlank()) throw BadRequestException("Название не может быть пустым")
|
||||
lab.title = request.title.trim()
|
||||
@@ -167,42 +187,62 @@ class LabService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateVariant(labId: UUID, variantId: UUID, request: VariantSaveRequest): LabDetail {
|
||||
val variant = variantsRepo.findById(variantId)
|
||||
.orElseThrow { NotFoundException("Вариант не найден") }
|
||||
fun updateVariant(
|
||||
labId: UUID,
|
||||
variantId: UUID,
|
||||
request: VariantSaveRequest,
|
||||
): LabDetail {
|
||||
val variant =
|
||||
variantsRepo
|
||||
.findById(variantId)
|
||||
.orElseThrow { NotFoundException("Вариант не найден") }
|
||||
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
|
||||
if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым")
|
||||
variant.number = request.number.trim()
|
||||
variant.title = request.title.orEmpty().trim()
|
||||
variant.statementMd = request.statementMd.orEmpty()
|
||||
request.labLevelId.takeIf { it.isNotBlank() }?.let { levelId ->
|
||||
variant.labLevel = labLevels.findById(UUID.fromString(levelId))
|
||||
.orElseThrow { BadRequestException("Уровень не найден") }
|
||||
variant.labLevel =
|
||||
labLevels
|
||||
.findById(UUID.fromString(levelId))
|
||||
.orElseThrow { BadRequestException("Уровень не найден") }
|
||||
}
|
||||
variantsRepo.save(variant)
|
||||
return detail(labId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun addVariant(labId: UUID, request: VariantSaveRequest): LabDetail {
|
||||
fun addVariant(
|
||||
labId: UUID,
|
||||
request: VariantSaveRequest,
|
||||
): LabDetail {
|
||||
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
||||
if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым")
|
||||
val level = labLevels.findById(UUID.fromString(request.labLevelId))
|
||||
.orElseThrow { BadRequestException("Уровень не найден") }
|
||||
variantsRepo.save(Variant(
|
||||
lab = lab,
|
||||
labLevel = level,
|
||||
number = request.number.trim(),
|
||||
title = request.title.orEmpty().trim(),
|
||||
statementMd = request.statementMd.orEmpty(),
|
||||
))
|
||||
val level =
|
||||
labLevels
|
||||
.findById(UUID.fromString(request.labLevelId))
|
||||
.orElseThrow { BadRequestException("Уровень не найден") }
|
||||
variantsRepo.save(
|
||||
Variant(
|
||||
lab = lab,
|
||||
labLevel = level,
|
||||
number = request.number.trim(),
|
||||
title = request.title.orEmpty().trim(),
|
||||
statementMd = request.statementMd.orEmpty(),
|
||||
),
|
||||
)
|
||||
return detail(labId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteVariant(labId: UUID, variantId: UUID): LabDetail {
|
||||
val variant = variantsRepo.findById(variantId)
|
||||
.orElseThrow { NotFoundException("Вариант не найден") }
|
||||
fun deleteVariant(
|
||||
labId: UUID,
|
||||
variantId: UUID,
|
||||
): LabDetail {
|
||||
val variant =
|
||||
variantsRepo
|
||||
.findById(variantId)
|
||||
.orElseThrow { NotFoundException("Вариант не найден") }
|
||||
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
|
||||
variantsRepo.delete(variant)
|
||||
return detail(labId)
|
||||
@@ -216,21 +256,23 @@ class LabService(
|
||||
if (labs.existsByNumberAndSemesterId(request.number ?: 0, semester.id)) {
|
||||
throw ConflictException("Лабораторная №${request.number} уже существует в этом семестре")
|
||||
}
|
||||
val lab = labs.save(
|
||||
Lab(
|
||||
number = request.number ?: 0,
|
||||
title = request.title.orEmpty().trim(),
|
||||
semester = semester,
|
||||
descriptionMd = request.descriptionMd.orEmpty(),
|
||||
deadlineAt = parseInstant(request.deadlineAt),
|
||||
defenseAt = parseInstant(request.defenseAt),
|
||||
pointsMax = request.pointsMax ?: BigDecimal(5),
|
||||
status = request.status ?: "draft",
|
||||
),
|
||||
)
|
||||
val levelMetas = request.levels.ifEmpty {
|
||||
listOf(LevelSaveRequest(code = "basic", title = "Основная группа"))
|
||||
}
|
||||
val lab =
|
||||
labs.save(
|
||||
Lab(
|
||||
number = request.number ?: 0,
|
||||
title = request.title.orEmpty().trim(),
|
||||
semester = semester,
|
||||
descriptionMd = request.descriptionMd.orEmpty(),
|
||||
deadlineAt = parseInstant(request.deadlineAt),
|
||||
defenseAt = parseInstant(request.defenseAt),
|
||||
pointsMax = request.pointsMax ?: BigDecimal(5),
|
||||
status = request.status ?: "draft",
|
||||
),
|
||||
)
|
||||
val levelMetas =
|
||||
request.levels.ifEmpty {
|
||||
listOf(LevelSaveRequest(code = "basic", title = "Основная группа"))
|
||||
}
|
||||
levelMetas.forEachIndexed { index, meta ->
|
||||
if (meta.code.isNullOrBlank()) throw BadRequestException("Код уровня не может быть пустым")
|
||||
labLevels.save(
|
||||
@@ -247,7 +289,11 @@ class LabService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun renameLevel(labId: UUID, levelId: UUID, request: LevelRenameRequest): LabDetail {
|
||||
fun renameLevel(
|
||||
labId: UUID,
|
||||
levelId: UUID,
|
||||
request: LevelRenameRequest,
|
||||
): LabDetail {
|
||||
val level = labLevels.findById(levelId).orElseThrow { NotFoundException("Уровень не найден") }
|
||||
if (level.lab?.id != labId) throw NotFoundException("Уровень не найден")
|
||||
level.title = request.title.ifBlank { level.title }
|
||||
@@ -257,7 +303,10 @@ class LabService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteLevel(labId: UUID, levelId: UUID): LabDetail {
|
||||
fun deleteLevel(
|
||||
labId: UUID,
|
||||
levelId: UUID,
|
||||
): LabDetail {
|
||||
val level = labLevels.findById(levelId).orElseThrow { NotFoundException("Уровень не найден") }
|
||||
if (level.lab?.id != labId) throw NotFoundException("Уровень не найден")
|
||||
if (variantsRepo.findByLabIdOrderByNumber(labId).any { it.labLevel?.id == levelId }) {
|
||||
@@ -267,8 +316,11 @@ class LabService(
|
||||
return detail(labId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun addLevel(labId: UUID, request: LevelSaveRequest) {
|
||||
@Transactional
|
||||
fun addLevel(
|
||||
labId: UUID,
|
||||
request: LevelSaveRequest,
|
||||
) {
|
||||
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
||||
if (request.code.isBlank()) throw BadRequestException("Код уровня не может быть пустым")
|
||||
val existing = labLevels.findByLabIdOrderByPosition(labId)
|
||||
@@ -290,15 +342,24 @@ class LabService(
|
||||
labs.save(lab)
|
||||
}
|
||||
|
||||
private fun findOrCreateSemester(title: String, year: Int): Semester {
|
||||
private fun findOrCreateSemester(
|
||||
title: String,
|
||||
year: Int,
|
||||
): Semester {
|
||||
semesters.findFirstByActiveTrue()?.let { return it }
|
||||
val t = title.ifBlank { "осень" }
|
||||
val y = if (year > 0) year else java.time.LocalDate.now().year
|
||||
val y =
|
||||
if (year > 0) {
|
||||
year
|
||||
} else {
|
||||
java.time.LocalDate
|
||||
.now()
|
||||
.year
|
||||
}
|
||||
return semesters.save(Semester(title = t, year = y, active = true))
|
||||
}
|
||||
|
||||
private fun naturalNumber(value: String): Int =
|
||||
Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
|
||||
private fun naturalNumber(value: String): Int = Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
|
||||
|
||||
private fun parseInstant(value: String?): Instant? {
|
||||
val v = value?.takeIf { it.isNotBlank() } ?: return null
|
||||
@@ -307,4 +368,4 @@ class LabService(
|
||||
.recoverCatching { LocalDateTime.parse(v).atZone(ZoneId.of("Europe/Moscow")).toInstant() }
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,20 +8,40 @@ import java.util.UUID
|
||||
|
||||
interface ContentChapterRepository : JpaRepository<ContentChapter, UUID> {
|
||||
fun findAllByOrderByPositionAscIdAsc(): List<ContentChapter>
|
||||
|
||||
fun findBySlug(slug: String): ContentChapter?
|
||||
|
||||
fun findByTitleIgnoreCase(title: String): ContentChapter?
|
||||
}
|
||||
|
||||
interface ContentSectionRepository : JpaRepository<ContentSection, UUID> {
|
||||
fun findAllByOrderByPositionAscIdAsc(): List<ContentSection>
|
||||
|
||||
fun findByChapterIdOrderByPositionAscIdAsc(chapterId: UUID): List<ContentSection>
|
||||
fun findByChapterIdAndSlug(chapterId: UUID, slug: String): ContentSection?
|
||||
fun findByChapterIdAndTitleIgnoreCase(chapterId: UUID, title: String): ContentSection?
|
||||
|
||||
fun findByChapterIdAndSlug(
|
||||
chapterId: UUID,
|
||||
slug: String,
|
||||
): ContentSection?
|
||||
|
||||
fun findByChapterIdAndTitleIgnoreCase(
|
||||
chapterId: UUID,
|
||||
title: String,
|
||||
): ContentSection?
|
||||
}
|
||||
|
||||
interface ContentArticleRepository : JpaRepository<ContentArticle, UUID> {
|
||||
fun findBySectionIdOrderByPositionAscIdAsc(sectionId: UUID): List<ContentArticle>
|
||||
fun findBySectionIdAndSlug(sectionId: UUID, slug: String): ContentArticle?
|
||||
fun findBySectionIdAndTitleIgnoreCase(sectionId: UUID, title: String): ContentArticle?
|
||||
|
||||
fun findBySectionIdAndSlug(
|
||||
sectionId: UUID,
|
||||
slug: String,
|
||||
): ContentArticle?
|
||||
|
||||
fun findBySectionIdAndTitleIgnoreCase(
|
||||
sectionId: UUID,
|
||||
title: String,
|
||||
): ContentArticle?
|
||||
|
||||
fun findAllByOrderByPositionAscIdAsc(): List<ContentArticle>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,9 @@ import java.util.UUID
|
||||
|
||||
interface LabRepository : JpaRepository<Lab, UUID> {
|
||||
fun findAllByOrderByNumberAsc(): List<Lab>
|
||||
fun existsByNumberAndSemesterId(number: Int, semesterId: UUID): Boolean
|
||||
}
|
||||
|
||||
fun existsByNumberAndSemesterId(
|
||||
number: Int,
|
||||
semesterId: UUID,
|
||||
): Boolean
|
||||
}
|
||||
|
||||
@@ -6,4 +6,4 @@ import java.util.UUID
|
||||
|
||||
interface SemesterRepository : JpaRepository<Semester, UUID> {
|
||||
fun findFirstByActiveTrue(): Semester?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,4 @@ interface LabLevelRepository : JpaRepository<LabLevel, UUID> {
|
||||
|
||||
interface VariantRepository : JpaRepository<Variant, UUID> {
|
||||
fun findByLabIdOrderByNumber(labId: UUID): List<Variant>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||
import org.springframework.security.config.http.SessionCreationPolicy
|
||||
import org.springframework.security.core.GrantedAuthority
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder
|
||||
@@ -20,18 +19,19 @@ import org.springframework.security.web.SecurityFilterChain
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
fun jwtDecoder(): JwtDecoder =
|
||||
NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
|
||||
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
|
||||
|
||||
@Bean
|
||||
fun jwtAuthenticationConverter(): Converter<Jwt, out AbstractAuthenticationToken> {
|
||||
val converter = JwtAuthenticationConverter()
|
||||
converter.setJwtGrantedAuthoritiesConverter { jwt ->
|
||||
val role = jwt.getClaimAsString("role")
|
||||
if (role.isNullOrBlank()) emptyList()
|
||||
else listOf(SimpleGrantedAuthority("ROLE_$role"))
|
||||
if (role.isNullOrBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(SimpleGrantedAuthority("ROLE_$role"))
|
||||
}
|
||||
}
|
||||
return converter
|
||||
}
|
||||
@@ -43,17 +43,17 @@ class SecurityConfig {
|
||||
.cors { it.disable() }
|
||||
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
||||
.authorizeHttpRequests { auth ->
|
||||
auth.requestMatchers(
|
||||
"/actuator/**",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-ui/**",
|
||||
"/api/v1/content/tree",
|
||||
"/api/v1/content/assets/**",
|
||||
"/api/v1/content/{chapter}/{section}/{article}",
|
||||
).permitAll()
|
||||
auth
|
||||
.requestMatchers(
|
||||
"/actuator/**",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-ui/**",
|
||||
"/api/v1/content/tree",
|
||||
"/api/v1/content/assets/**",
|
||||
"/api/v1/content/{chapter}/{section}/{article}",
|
||||
).permitAll()
|
||||
auth.anyRequest().authenticated()
|
||||
}
|
||||
.oauth2ResourceServer { oauth ->
|
||||
}.oauth2ResourceServer { oauth ->
|
||||
oauth.jwt { jwt ->
|
||||
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
|
||||
}
|
||||
@@ -64,4 +64,4 @@ class SecurityConfig {
|
||||
companion object {
|
||||
private val JWK_URI = System.getenv("LMS_JWT_JWK_SET_URI") ?: "http://localhost:8081/api/v1/auth/jwks.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package ru.ysu.lms.academic.lab
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.junit.jupiter.api.Test
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
class LabImportParserTest {
|
||||
private val parser = LabImportParser()
|
||||
|
||||
private fun zip(vararg files: Pair<String, String>): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
ZipOutputStream(out).use { zos ->
|
||||
for ((name, content) in files) {
|
||||
zos.putNextEntry(ZipEntry(name))
|
||||
zos.write(content.toByteArray(Charsets.UTF_8))
|
||||
zos.closeEntry()
|
||||
}
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
private val labYaml =
|
||||
"""
|
||||
number: 2
|
||||
title: "Многомерные массивы и функции"
|
||||
levels:
|
||||
- code: basic
|
||||
title: "Основная"
|
||||
- code: advanced
|
||||
title: "Повышенная"
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `parses lab metadata, readme and variants`() {
|
||||
val bundle =
|
||||
parser.parse(
|
||||
"lab.zip",
|
||||
zip(
|
||||
"lab.yaml" to labYaml,
|
||||
"README.md" to "# Условие лабораторной",
|
||||
"variants/1.md" to "---\nlevel: basic\nnumber: \"1\"\ntitle: \"Сумма\"\n---\nНайдите сумму",
|
||||
"variants/p1.md" to "---\nlevel: advanced\nnumber: \"П1\"\ntitle: \"Сложная\"\n---\nЗадание",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(bundle.meta.number).isEqualTo(2)
|
||||
assertThat(bundle.meta.title).isEqualTo("Многомерные массивы и функции")
|
||||
assertThat(bundle.meta.levels).hasSize(2)
|
||||
assertThat(bundle.descriptionMd).contains("Условие лабораторной")
|
||||
assertThat(bundle.variants).hasSize(2)
|
||||
assertThat(bundle.variants.map { it.number }).containsExactlyInAnyOrder("1", "П1")
|
||||
assertThat(bundle.variants.first { it.number == "1" }.level).isEqualTo("basic")
|
||||
assertThat(bundle.variants.first { it.number == "1" }.statement).isEqualTo("Найдите сумму")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `treats file without front matter as basic variant`() {
|
||||
val bundle =
|
||||
parser.parse(
|
||||
"lab.zip",
|
||||
zip(
|
||||
"lab.yaml" to labYaml,
|
||||
"variants/7.md" to "Просто текст задания",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(bundle.variants).hasSize(1)
|
||||
assertThat(bundle.variants.single().level).isEqualTo("basic")
|
||||
assertThat(bundle.variants.single().statement).contains("Просто текст задания")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects non zip file`() {
|
||||
assertThatThrownBy { parser.parse("lab.txt", ByteArray(0)) }
|
||||
.isInstanceOf(BadRequestException::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects zip without lab yaml`() {
|
||||
assertThatThrownBy { parser.parse("lab.zip", zip("README.md" to "# Условие")) }
|
||||
.isInstanceOf(BadRequestException::class.java)
|
||||
.hasMessageContaining("lab.yaml")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects zip without variants`() {
|
||||
assertThatThrownBy { parser.parse("lab.zip", zip("lab.yaml" to labYaml, "README.md" to "# Условие")) }
|
||||
.isInstanceOf(BadRequestException::class.java)
|
||||
.hasMessageContaining("варианты")
|
||||
}
|
||||
}
|
||||
@@ -16,22 +16,30 @@ import reactor.core.publisher.Mono
|
||||
* identity to the downstream services as `X-Account-*` headers.
|
||||
*/
|
||||
@Component
|
||||
class JwtAuthenticationFilter(private val jwtDecoder: JwtDecoder) : GlobalFilter, Ordered {
|
||||
|
||||
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
|
||||
class JwtAuthenticationFilter(
|
||||
private val jwtDecoder: JwtDecoder,
|
||||
) : GlobalFilter,
|
||||
Ordered {
|
||||
override fun filter(
|
||||
exchange: ServerWebExchange,
|
||||
chain: GatewayFilterChain,
|
||||
): Mono<Void> {
|
||||
val path = exchange.request.path.value()
|
||||
if (isPublic(path)) {
|
||||
return chain.filter(exchange)
|
||||
}
|
||||
val token = bearerToken(exchange)
|
||||
?: return unauthorized(exchange, "Требуется вход")
|
||||
val token =
|
||||
bearerToken(exchange)
|
||||
?: return unauthorized(exchange, "Требуется вход")
|
||||
return try {
|
||||
val jwt = jwtDecoder.decode(token)
|
||||
val request = exchange.request.mutate()
|
||||
.header("X-Account-Id", jwt.subject.orEmpty())
|
||||
.header("X-Account-Login", jwt.getClaimAsString("login").orEmpty())
|
||||
.header("X-Account-Role", jwt.getClaimAsString("role").orEmpty())
|
||||
.build()
|
||||
val request =
|
||||
exchange.request
|
||||
.mutate()
|
||||
.header("X-Account-Id", jwt.subject.orEmpty())
|
||||
.header("X-Account-Login", jwt.getClaimAsString("login").orEmpty())
|
||||
.header("X-Account-Role", jwt.getClaimAsString("role").orEmpty())
|
||||
.build()
|
||||
chain.filter(exchange.mutate().request(request).build())
|
||||
} catch (_: JwtException) {
|
||||
unauthorized(exchange, "Недействительный или истёкший токен")
|
||||
@@ -56,7 +64,10 @@ class JwtAuthenticationFilter(private val jwtDecoder: JwtDecoder) : GlobalFilter
|
||||
return header.removePrefix("Bearer ").trim().ifBlank { null }
|
||||
}
|
||||
|
||||
private fun unauthorized(exchange: ServerWebExchange, message: String): Mono<Void> {
|
||||
private fun unauthorized(
|
||||
exchange: ServerWebExchange,
|
||||
message: String,
|
||||
): Mono<Void> {
|
||||
val response = exchange.response
|
||||
response.statusCode = HttpStatus.UNAUTHORIZED
|
||||
response.headers.contentType = MediaType.APPLICATION_JSON
|
||||
|
||||
@@ -18,23 +18,28 @@ class AuthController(
|
||||
private val authService: AuthService,
|
||||
private val rsaKey: RSAKey,
|
||||
) {
|
||||
|
||||
@GetMapping("/health")
|
||||
fun health(): Map<String, Any> = mapOf(
|
||||
"ok" to true,
|
||||
"service" to "identity-service",
|
||||
"adminExists" to authService.adminExists(),
|
||||
)
|
||||
fun health(): Map<String, Any> =
|
||||
mapOf(
|
||||
"ok" to true,
|
||||
"service" to "identity-service",
|
||||
"adminExists" to authService.adminExists(),
|
||||
)
|
||||
|
||||
@PostMapping("/setup")
|
||||
fun setup(@Valid @RequestBody body: SetupRequest): AuthResponse = authService.setup(body)
|
||||
fun setup(
|
||||
@Valid @RequestBody body: SetupRequest,
|
||||
): AuthResponse = authService.setup(body)
|
||||
|
||||
@PostMapping("/login")
|
||||
fun login(@Valid @RequestBody body: LoginRequest): AuthResponse = authService.login(body)
|
||||
fun login(
|
||||
@Valid @RequestBody body: LoginRequest,
|
||||
): AuthResponse = authService.login(body)
|
||||
|
||||
@GetMapping("/me")
|
||||
fun me(@AuthenticationPrincipal jwt: Jwt): MeResponse =
|
||||
authService.me(UUID.fromString(jwt.subject))
|
||||
fun me(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): MeResponse = authService.me(UUID.fromString(jwt.subject))
|
||||
|
||||
@PostMapping("/change-password")
|
||||
fun changePassword(
|
||||
|
||||
@@ -28,7 +28,6 @@ class AuthService(
|
||||
private val jwtEncoder: JwtEncoder,
|
||||
private val jwtProperties: JwtProperties,
|
||||
) {
|
||||
|
||||
fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN)
|
||||
|
||||
@Transactional
|
||||
@@ -41,20 +40,22 @@ class AuthService(
|
||||
throw BadRequestException("Логин: минимум 3 символа")
|
||||
}
|
||||
validatePassword(request.password, request.password2)
|
||||
val account = Account(
|
||||
login = login,
|
||||
passwordHash = passwordEncoder.encode(request.password) ?: "",
|
||||
role = LmsRole.ADMIN,
|
||||
mustChangePassword = false,
|
||||
)
|
||||
val account =
|
||||
Account(
|
||||
login = login,
|
||||
passwordHash = passwordEncoder.encode(request.password) ?: "",
|
||||
role = LmsRole.ADMIN,
|
||||
mustChangePassword = false,
|
||||
)
|
||||
accounts.save(account)
|
||||
return toResponse(account, account.login)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun login(request: LoginRequest): AuthResponse {
|
||||
val account = accounts.findByLogin(request.login.trim())
|
||||
?: throw UnauthorizedException("Неверный логин или пароль")
|
||||
val account =
|
||||
accounts.findByLogin(request.login.trim())
|
||||
?: throw UnauthorizedException("Неверный логин или пароль")
|
||||
if (!passwordEncoder.matches(request.password, account.passwordHash)) {
|
||||
throw UnauthorizedException("Неверный логин или пароль")
|
||||
}
|
||||
@@ -63,8 +64,10 @@ class AuthService(
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun me(accountId: UUID): MeResponse {
|
||||
val account = accounts.findById(accountId)
|
||||
.orElseThrow { NotFoundException("Пользователь не найден") }
|
||||
val account =
|
||||
accounts
|
||||
.findById(accountId)
|
||||
.orElseThrow { NotFoundException("Пользователь не найден") }
|
||||
return MeResponse(
|
||||
id = account.id.toString(),
|
||||
login = account.login,
|
||||
@@ -75,9 +78,14 @@ class AuthService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun changePassword(accountId: UUID, request: ChangePasswordRequest) {
|
||||
val account = accounts.findById(accountId)
|
||||
.orElseThrow { NotFoundException("Пользователь не найден") }
|
||||
fun changePassword(
|
||||
accountId: UUID,
|
||||
request: ChangePasswordRequest,
|
||||
) {
|
||||
val account =
|
||||
accounts
|
||||
.findById(accountId)
|
||||
.orElseThrow { NotFoundException("Пользователь не найден") }
|
||||
if (!passwordEncoder.matches(request.old, account.passwordHash)) {
|
||||
throw UnauthorizedException("Текущий пароль неверный")
|
||||
}
|
||||
@@ -89,7 +97,10 @@ class AuthService(
|
||||
accounts.save(account)
|
||||
}
|
||||
|
||||
private fun validatePassword(password: String, confirmation: String) {
|
||||
private fun validatePassword(
|
||||
password: String,
|
||||
confirmation: String,
|
||||
) {
|
||||
if (password.length < 8) {
|
||||
throw BadRequestException("Пароль: минимум 8 символов")
|
||||
}
|
||||
@@ -100,33 +111,43 @@ class AuthService(
|
||||
|
||||
private fun displayName(account: Account): String =
|
||||
if (account.role == LmsRole.STUDENT) {
|
||||
students.findById(account.id)
|
||||
students
|
||||
.findById(account.id)
|
||||
.map { it.fullName.ifBlank { account.login } }
|
||||
.orElse(account.login)
|
||||
} else {
|
||||
account.login
|
||||
}
|
||||
|
||||
private fun toResponse(account: Account, name: String): AuthResponse = AuthResponse(
|
||||
token = issueToken(account, name),
|
||||
id = account.id.toString(),
|
||||
login = account.login,
|
||||
role = account.role.name,
|
||||
name = name,
|
||||
mustChangePassword = account.mustChangePassword,
|
||||
)
|
||||
private fun toResponse(
|
||||
account: Account,
|
||||
name: String,
|
||||
): AuthResponse =
|
||||
AuthResponse(
|
||||
token = issueToken(account, name),
|
||||
id = account.id.toString(),
|
||||
login = account.login,
|
||||
role = account.role.name,
|
||||
name = name,
|
||||
mustChangePassword = account.mustChangePassword,
|
||||
)
|
||||
|
||||
private fun issueToken(account: Account, name: String): String {
|
||||
private fun issueToken(
|
||||
account: Account,
|
||||
name: String,
|
||||
): String {
|
||||
val now = Instant.now()
|
||||
val claims = JwtClaimsSet.builder()
|
||||
.issuer(jwtProperties.issuer)
|
||||
.issuedAt(now)
|
||||
.expiresAt(now.plus(jwtProperties.accessTokenTtl))
|
||||
.subject(account.id.toString())
|
||||
.claim("role", account.role.name)
|
||||
.claim("login", account.login)
|
||||
.claim("name", name)
|
||||
.build()
|
||||
val claims =
|
||||
JwtClaimsSet
|
||||
.builder()
|
||||
.issuer(jwtProperties.issuer)
|
||||
.issuedAt(now)
|
||||
.expiresAt(now.plus(jwtProperties.accessTokenTtl))
|
||||
.subject(account.id.toString())
|
||||
.claim("role", account.role.name)
|
||||
.claim("login", account.login)
|
||||
.claim("name", name)
|
||||
.build()
|
||||
val header = JwsHeader.with(SignatureAlgorithm.RS256).build()
|
||||
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).tokenValue
|
||||
}
|
||||
|
||||
@@ -16,26 +16,19 @@ import java.util.UUID
|
||||
class Account(
|
||||
@Id
|
||||
var id: UUID = Ids.v7(),
|
||||
|
||||
@Column(nullable = false, unique = true, length = 64)
|
||||
var login: String = "",
|
||||
|
||||
@Column(name = "password_hash", nullable = false)
|
||||
var passwordHash: String = "",
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 16)
|
||||
var role: LmsRole = LmsRole.STUDENT,
|
||||
|
||||
@Column(name = "must_change_password", nullable = false)
|
||||
var mustChangePassword: Boolean = true,
|
||||
|
||||
@Column(name = "temp_password", length = 64)
|
||||
var tempPassword: String? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: Instant = Instant.now(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Instant = Instant.now(),
|
||||
)
|
||||
|
||||
@@ -16,15 +16,12 @@ import java.util.UUID
|
||||
class Enrollment(
|
||||
@Id
|
||||
var id: UUID = Ids.v7(),
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "group_id", nullable = false)
|
||||
var group: GroupEntity? = null,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "student_id", nullable = false)
|
||||
var student: Student? = null,
|
||||
|
||||
@Column(name = "enrolled_at", nullable = false)
|
||||
var enrolledAt: Instant = Instant.now(),
|
||||
)
|
||||
|
||||
@@ -13,13 +13,10 @@ import java.util.UUID
|
||||
class GroupEntity(
|
||||
@Id
|
||||
var id: UUID = Ids.v7(),
|
||||
|
||||
@Column(nullable = false, unique = true, length = 100)
|
||||
var name: String = "",
|
||||
|
||||
@Column(name = "task_duration_min", nullable = false)
|
||||
var taskDurationMin: Int = 30,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: Instant = Instant.now(),
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ class Setting(
|
||||
@Id
|
||||
@Column(name = "key", length = 100)
|
||||
var key: String = "",
|
||||
|
||||
@Column(name = "value", nullable = false)
|
||||
var value: String = "",
|
||||
)
|
||||
|
||||
@@ -16,32 +16,26 @@ class Student(
|
||||
@Id
|
||||
@Column(name = "account_id")
|
||||
var accountId: UUID? = null,
|
||||
|
||||
@Column(name = "student_no", nullable = false, unique = true, length = 32)
|
||||
var studentNo: String = "",
|
||||
|
||||
@Column(name = "last_name", nullable = false, length = 100)
|
||||
var lastName: String = "",
|
||||
|
||||
@Column(name = "first_name", nullable = false, length = 100)
|
||||
var firstName: String = "",
|
||||
|
||||
@Column(name = "middle_name", nullable = false, length = 100)
|
||||
var middleName: String = "",
|
||||
|
||||
@Column(name = "basis", nullable = false, length = 100)
|
||||
var basis: String = "",
|
||||
|
||||
@Column(name = "note", nullable = false)
|
||||
var note: String = "",
|
||||
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@MapsId
|
||||
@JoinColumn(name = "account_id")
|
||||
var account: Account? = null,
|
||||
) {
|
||||
val fullName: String
|
||||
get() = listOf(lastName, firstName, middleName)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" ")
|
||||
get() =
|
||||
listOf(lastName, firstName, middleName)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" ")
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ import java.util.UUID
|
||||
|
||||
interface AccountRepository : JpaRepository<Account, UUID> {
|
||||
fun findByLogin(login: String): Account?
|
||||
|
||||
fun existsByLogin(login: String): Boolean
|
||||
|
||||
fun existsByRole(role: LmsRole): Boolean
|
||||
|
||||
fun countByRole(role: LmsRole): Long
|
||||
}
|
||||
|
||||
@@ -7,16 +7,18 @@ import ru.ysu.lms.identity.domain.Enrollment
|
||||
import java.util.UUID
|
||||
|
||||
interface EnrollmentRepository : JpaRepository<Enrollment, UUID> {
|
||||
|
||||
@Query(
|
||||
"select e from Enrollment e join fetch e.student s join fetch s.account where e.group.id = :groupId",
|
||||
)
|
||||
fun findAllByGroupId(@Param("groupId") groupId: UUID): List<Enrollment>
|
||||
fun findAllByGroupId(
|
||||
@Param("groupId") groupId: UUID,
|
||||
): List<Enrollment>
|
||||
|
||||
fun findByStudent_AccountId(accountId: UUID): Enrollment?
|
||||
fun findFirstByStudent_AccountId(accountId: UUID): Enrollment?
|
||||
fun findByStudentAccountId(accountId: UUID): Enrollment?
|
||||
|
||||
fun deleteByStudent_AccountId(accountId: UUID)
|
||||
fun findFirstByStudentAccountId(accountId: UUID): Enrollment?
|
||||
|
||||
fun countByGroup_Id(groupId: UUID): Long
|
||||
fun deleteByStudentAccountId(accountId: UUID)
|
||||
|
||||
fun countByGroupId(groupId: UUID): Long
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import java.util.UUID
|
||||
|
||||
interface GroupRepository : JpaRepository<GroupEntity, UUID> {
|
||||
fun findByName(name: String): GroupEntity?
|
||||
|
||||
fun existsByName(name: String): Boolean
|
||||
|
||||
fun findAllByOrderByNameAsc(): List<GroupEntity>
|
||||
}
|
||||
|
||||
@@ -6,5 +6,6 @@ import java.util.UUID
|
||||
|
||||
interface StudentRepository : JpaRepository<Student, UUID> {
|
||||
fun findByStudentNo(studentNo: String): Student?
|
||||
|
||||
fun existsByStudentNo(studentNo: String): Boolean
|
||||
}
|
||||
|
||||
@@ -4,21 +4,21 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
object CredentialsExporter {
|
||||
|
||||
private val HEADERS = listOf("ФИО", "Логин", "Пароль", "Статус")
|
||||
|
||||
fun csv(rows: List<StudentRow>): ByteArray {
|
||||
val builder = StringBuilder()
|
||||
builder.append(HEADERS.joinToString(";")).append("\r\n")
|
||||
rows.forEach { row ->
|
||||
builder.append(
|
||||
listOf(
|
||||
row.fullName,
|
||||
row.studentNo,
|
||||
row.tempPassword ?: "—",
|
||||
status(row),
|
||||
).joinToString(";") { escapeCsv(it) },
|
||||
).append("\r\n")
|
||||
builder
|
||||
.append(
|
||||
listOf(
|
||||
row.fullName,
|
||||
row.studentNo,
|
||||
row.tempPassword ?: "—",
|
||||
status(row),
|
||||
).joinToString(";") { escapeCsv(it) },
|
||||
).append("\r\n")
|
||||
}
|
||||
return ("\uFEFF" + builder).toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
@@ -41,8 +41,7 @@ object CredentialsExporter {
|
||||
}
|
||||
}
|
||||
|
||||
private fun status(row: StudentRow): String =
|
||||
if (row.mustChangePassword) "ожидает первую смену пароля" else "пароль изменён"
|
||||
private fun status(row: StudentRow): String = if (row.mustChangePassword) "ожидает первую смену пароля" else "пароль изменён"
|
||||
|
||||
private fun escapeCsv(value: String): String =
|
||||
if (value.contains(';') || value.contains('"') || value.contains('\n')) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
@@ -17,20 +19,21 @@ import org.springframework.web.multipart.MultipartFile
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/groups")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class GroupController(private val groupService: GroupService) {
|
||||
|
||||
class GroupController(
|
||||
private val groupService: GroupService,
|
||||
) {
|
||||
@GetMapping
|
||||
fun list(): List<GroupSummary> = groupService.list()
|
||||
|
||||
@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(
|
||||
@@ -51,25 +54,34 @@ class GroupController(private val groupService: GroupService) {
|
||||
|
||||
/** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */
|
||||
@GetMapping("/{id}/candidates")
|
||||
fun candidates(@PathVariable id: UUID): List<StudentCandidate> = groupService.candidates(id)
|
||||
fun candidates(
|
||||
@PathVariable id: UUID,
|
||||
): List<StudentCandidate> = groupService.candidates(id)
|
||||
|
||||
/** Зачислить существующего студента. */
|
||||
@PostMapping("/{id}/students/{studentId}/enroll")
|
||||
fun enroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail =
|
||||
groupService.enroll(id, studentId)
|
||||
fun enroll(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable studentId: UUID,
|
||||
): GroupDetail = groupService.enroll(id, studentId)
|
||||
|
||||
/** Отчислить студента из группы. */
|
||||
@DeleteMapping("/{id}/students/{studentId}")
|
||||
fun unenroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail =
|
||||
groupService.unenroll(id, studentId)
|
||||
fun unenroll(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable studentId: UUID,
|
||||
): GroupDetail = groupService.unenroll(id, studentId)
|
||||
|
||||
@GetMapping("/me")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
fun myGroup(@AuthenticationPrincipal jwt: Jwt): List<GroupSummary> =
|
||||
groupService.groupOfStudent(UUID.fromString(jwt.subject))?.let { listOf(it) } ?: emptyList()
|
||||
fun myGroup(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<GroupSummary> = groupService.groupOfStudent(UUID.fromString(jwt.subject))?.let { listOf(it) } ?: emptyList()
|
||||
|
||||
@GetMapping("/{id}")
|
||||
fun detail(@PathVariable id: UUID): GroupDetail = groupService.detail(id)
|
||||
fun detail(
|
||||
@PathVariable id: UUID,
|
||||
): GroupDetail = groupService.detail(id)
|
||||
|
||||
@GetMapping("/{id}/credentials")
|
||||
fun credentials(
|
||||
@@ -78,7 +90,8 @@ class GroupController(private val groupService: GroupService) {
|
||||
): ResponseEntity<ByteArray> {
|
||||
val export = groupService.credentials(id, format)
|
||||
val encodedName = URLEncoder.encode(export.filename, StandardCharsets.UTF_8).replace("+", "%20")
|
||||
return ResponseEntity.ok()
|
||||
return ResponseEntity
|
||||
.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedName")
|
||||
.contentType(MediaType.parseMediaType(export.contentType))
|
||||
.body(export.bytes)
|
||||
|
||||
@@ -27,12 +27,14 @@ class GroupService(
|
||||
private val passwordEncoder: PasswordEncoder,
|
||||
private val parser: StudentImportParser,
|
||||
) {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun list(): List<GroupSummary> = groups.findAllByOrderByNameAsc().map { summary(it) }
|
||||
|
||||
@Transactional
|
||||
fun createStudent(groupId: UUID, request: CreateStudentRequest): Credential {
|
||||
fun createStudent(
|
||||
groupId: UUID,
|
||||
request: CreateStudentRequest,
|
||||
): Credential {
|
||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||
val studentNo = request.studentNo.trim()
|
||||
if (studentNo.isBlank()) throw BadRequestException("Студенческий номер не может быть пустым")
|
||||
@@ -40,27 +42,29 @@ class GroupService(
|
||||
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 account =
|
||||
accounts.save(
|
||||
Account(
|
||||
login = studentNo,
|
||||
passwordHash = passwordEncoder.encode(password) ?: "",
|
||||
role = LmsRole.STUDENT,
|
||||
mustChangePassword = true,
|
||||
tempPassword = password,
|
||||
),
|
||||
)
|
||||
// Student.id — это account_id через @MapsId: ассоциацию задаём до persist.
|
||||
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(),
|
||||
account = account,
|
||||
),
|
||||
)
|
||||
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(),
|
||||
account = account,
|
||||
),
|
||||
)
|
||||
enrollments.save(Enrollment(group = group, student = student))
|
||||
return Credential(
|
||||
id = account.id.toString(),
|
||||
@@ -91,7 +95,8 @@ class GroupService(
|
||||
val name = e.group?.name ?: continue
|
||||
currentByStudent[sid] = gid to name
|
||||
}
|
||||
return students.findAll()
|
||||
return students
|
||||
.findAll()
|
||||
.filter { it.accountId !in memberIds }
|
||||
.map { s ->
|
||||
val current = s.accountId?.let { currentByStudent[it] }
|
||||
@@ -102,16 +107,18 @@ class GroupService(
|
||||
currentGroupId = current?.first?.toString(),
|
||||
currentGroupName = current?.second,
|
||||
)
|
||||
}
|
||||
.sortedWith(compareBy({ it.fullName }, { it.studentNo }))
|
||||
}.sortedWith(compareBy({ it.fullName }, { it.studentNo }))
|
||||
}
|
||||
|
||||
/** Зачислить существующего студента; если он в другой группе — переводим. */
|
||||
@Transactional
|
||||
fun enroll(groupId: UUID, studentId: UUID): GroupDetail {
|
||||
fun enroll(
|
||||
groupId: UUID,
|
||||
studentId: UUID,
|
||||
): GroupDetail {
|
||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||
val student = students.findById(studentId).orElseThrow { NotFoundException("Студент не найден") }
|
||||
val existing = enrollments.findByStudent_AccountId(studentId)
|
||||
val existing = enrollments.findByStudentAccountId(studentId)
|
||||
if (existing != null) {
|
||||
if (existing.group?.id == group.id) {
|
||||
throw ConflictException("Студент уже в этой группе")
|
||||
@@ -127,15 +134,23 @@ class GroupService(
|
||||
|
||||
/** Отчислить из группы (студент и его аккаунт сохраняются). */
|
||||
@Transactional
|
||||
fun unenroll(groupId: UUID, studentId: UUID): GroupDetail {
|
||||
val enrollment = enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
|
||||
?: throw NotFoundException("Студент не входит в эту группу")
|
||||
fun unenroll(
|
||||
groupId: UUID,
|
||||
studentId: UUID,
|
||||
): GroupDetail {
|
||||
val enrollment =
|
||||
enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
|
||||
?: throw NotFoundException("Студент не входит в эту группу")
|
||||
enrollments.delete(enrollment)
|
||||
return detail(groupId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun import(requestedName: String?, filename: String, bytes: ByteArray): ImportResult {
|
||||
fun import(
|
||||
requestedName: String?,
|
||||
filename: String,
|
||||
bytes: ByteArray,
|
||||
): ImportResult {
|
||||
val parsed = parser.parse(filename, bytes)
|
||||
val groupName = requestedName?.trim().takeUnless { it.isNullOrBlank() } ?: parsed.groupName
|
||||
val group = groups.findByName(groupName) ?: groups.save(GroupEntity(name = groupName))
|
||||
@@ -144,49 +159,52 @@ class GroupService(
|
||||
|
||||
parsed.students.forEach { parsedStudent ->
|
||||
val existing = students.findByStudentNo(parsedStudent.studentNo)
|
||||
val 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 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.findByStudent_AccountId(accountId)
|
||||
val enrollment = enrollments.findByStudentAccountId(accountId)
|
||||
if (enrollment == null) {
|
||||
enrollments.save(Enrollment(group = group, student = student))
|
||||
} else {
|
||||
@@ -199,7 +217,7 @@ class GroupService(
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun groupOfStudent(accountId: UUID): GroupSummary? {
|
||||
val group = enrollments.findByStudent_AccountId(accountId)?.group ?: return null
|
||||
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null
|
||||
return GroupSummary(
|
||||
id = group.id.toString(),
|
||||
name = group.name,
|
||||
@@ -211,25 +229,30 @@ class GroupService(
|
||||
@Transactional(readOnly = true)
|
||||
fun detail(groupId: UUID): GroupDetail {
|
||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||
val rows = enrollments.findAllByGroupId(groupId).map { enrollment ->
|
||||
val student = enrollment.student!!
|
||||
val account = student.account!!
|
||||
StudentRow(
|
||||
id = account.id.toString(),
|
||||
studentNo = student.studentNo,
|
||||
fullName = student.fullName,
|
||||
basis = student.basis,
|
||||
mustChangePassword = account.mustChangePassword,
|
||||
tempPassword = account.tempPassword,
|
||||
)
|
||||
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
|
||||
val rows =
|
||||
enrollments
|
||||
.findAllByGroupId(groupId)
|
||||
.map { enrollment ->
|
||||
val student = enrollment.student!!
|
||||
val account = student.account!!
|
||||
StudentRow(
|
||||
id = account.id.toString(),
|
||||
studentNo = student.studentNo,
|
||||
fullName = student.fullName,
|
||||
basis = student.basis,
|
||||
mustChangePassword = account.mustChangePassword,
|
||||
tempPassword = account.tempPassword,
|
||||
)
|
||||
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
|
||||
return GroupDetail(group.id.toString(), group.name, group.taskDurationMin, rows)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun resetPassword(studentAccountId: UUID): ResetPasswordResponse {
|
||||
val student = students.findById(studentAccountId)
|
||||
.orElseThrow { NotFoundException("Студент не найден") }
|
||||
val student =
|
||||
students
|
||||
.findById(studentAccountId)
|
||||
.orElseThrow { NotFoundException("Студент не найден") }
|
||||
val account = student.account ?: throw NotFoundException("Аккаунт студента не найден")
|
||||
val password = Passwords.generate()
|
||||
account.passwordHash = passwordEncoder.encode(password) ?: account.passwordHash
|
||||
@@ -240,28 +263,34 @@ class GroupService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun credentials(groupId: UUID, format: String): ExportFile {
|
||||
fun credentials(
|
||||
groupId: UUID,
|
||||
format: String,
|
||||
): ExportFile {
|
||||
val detail = detail(groupId)
|
||||
val safeName = detail.name.replace(Regex("[^\\p{L}\\p{N}._-]+"), "_")
|
||||
return when (format.lowercase()) {
|
||||
"csv" -> ExportFile(
|
||||
bytes = CredentialsExporter.csv(detail.students),
|
||||
filename = "credentials_$safeName.csv",
|
||||
contentType = "text/csv; charset=utf-8",
|
||||
)
|
||||
"xlsx" -> ExportFile(
|
||||
bytes = CredentialsExporter.xlsx(detail.students),
|
||||
filename = "credentials_$safeName.xlsx",
|
||||
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
"csv" ->
|
||||
ExportFile(
|
||||
bytes = CredentialsExporter.csv(detail.students),
|
||||
filename = "credentials_$safeName.csv",
|
||||
contentType = "text/csv; charset=utf-8",
|
||||
)
|
||||
"xlsx" ->
|
||||
ExportFile(
|
||||
bytes = CredentialsExporter.xlsx(detail.students),
|
||||
filename = "credentials_$safeName.xlsx",
|
||||
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
else -> throw BadRequestException("Формат должен быть xlsx или csv")
|
||||
}
|
||||
}
|
||||
|
||||
private fun summary(group: GroupEntity): GroupSummary = GroupSummary(
|
||||
id = group.id.toString(),
|
||||
name = group.name,
|
||||
studentCount = enrollments.countByGroup_Id(group.id),
|
||||
taskDurationMin = group.taskDurationMin,
|
||||
)
|
||||
private fun summary(group: GroupEntity): GroupSummary =
|
||||
GroupSummary(
|
||||
id = group.id.toString(),
|
||||
name = group.name,
|
||||
studentCount = enrollments.countByGroupId(group.id),
|
||||
taskDurationMin = group.taskDurationMin,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ import java.util.UUID
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/students")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class StudentController(private val groupService: GroupService) {
|
||||
|
||||
class StudentController(
|
||||
private val groupService: GroupService,
|
||||
) {
|
||||
@PostMapping("/{id}/reset-password")
|
||||
fun resetPassword(@PathVariable id: UUID): ResetPasswordResponse =
|
||||
groupService.resetPassword(id)
|
||||
fun resetPassword(
|
||||
@PathVariable id: UUID,
|
||||
): ResetPasswordResponse = groupService.resetPassword(id)
|
||||
}
|
||||
|
||||
@@ -25,8 +25,10 @@ data class ParsedGroup(
|
||||
|
||||
@Component
|
||||
class StudentImportParser {
|
||||
|
||||
fun parse(filename: String, bytes: ByteArray): ParsedGroup {
|
||||
fun parse(
|
||||
filename: String,
|
||||
bytes: ByteArray,
|
||||
): ParsedGroup {
|
||||
val lower = filename.lowercase()
|
||||
return when {
|
||||
lower.endsWith(".csv") -> parseRows(filename, parseCsv(bytes))
|
||||
@@ -65,7 +67,10 @@ class StudentImportParser {
|
||||
return candidates.maxByOrNull { header.count { ch -> ch == it } } ?: ';'
|
||||
}
|
||||
|
||||
private fun splitCsvLine(line: String, delimiter: Char): List<String> {
|
||||
private fun splitCsvLine(
|
||||
line: String,
|
||||
delimiter: Char,
|
||||
): List<String> {
|
||||
val result = mutableListOf<String>()
|
||||
val current = StringBuilder()
|
||||
var inQuotes = false
|
||||
@@ -83,7 +88,10 @@ class StudentImportParser {
|
||||
return result
|
||||
}
|
||||
|
||||
private fun parseRows(filename: String, rows: List<List<String>>): ParsedGroup {
|
||||
private fun parseRows(
|
||||
filename: String,
|
||||
rows: List<List<String>>,
|
||||
): ParsedGroup {
|
||||
val headerIndex = rows.indexOfFirst { row -> row.any { isStudentHeader(it) } }
|
||||
if (headerIndex < 0) {
|
||||
throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.")
|
||||
@@ -92,25 +100,26 @@ class StudentImportParser {
|
||||
if (columns["studentNo"] == null) {
|
||||
throw BadRequestException("Не найден столбец «Студенческий номер»")
|
||||
}
|
||||
val students = rows.drop(headerIndex + 1).mapNotNull { row ->
|
||||
fun cell(key: String): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
|
||||
val studentNo = normalizeNumber(cell("studentNo"))
|
||||
val lastName = cell("lastName")
|
||||
if (studentNo.isBlank() && lastName.isBlank()) {
|
||||
return@mapNotNull null
|
||||
val students =
|
||||
rows.drop(headerIndex + 1).mapNotNull { row ->
|
||||
fun cell(key: String): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
|
||||
val studentNo = normalizeNumber(cell("studentNo"))
|
||||
val lastName = cell("lastName")
|
||||
if (studentNo.isBlank() && lastName.isBlank()) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
if (studentNo.isBlank()) {
|
||||
throw BadRequestException("В одной из строк отсутствует студенческий номер")
|
||||
}
|
||||
ParsedStudent(
|
||||
studentNo = studentNo,
|
||||
lastName = lastName,
|
||||
firstName = cell("firstName"),
|
||||
middleName = cell("middleName"),
|
||||
basis = cell("basis"),
|
||||
note = cell("note"),
|
||||
)
|
||||
}
|
||||
if (studentNo.isBlank()) {
|
||||
throw BadRequestException("В одной из строк отсутствует студенческий номер")
|
||||
}
|
||||
ParsedStudent(
|
||||
studentNo = studentNo,
|
||||
lastName = lastName,
|
||||
firstName = cell("firstName"),
|
||||
middleName = cell("middleName"),
|
||||
basis = cell("basis"),
|
||||
note = cell("note"),
|
||||
)
|
||||
}
|
||||
if (students.isEmpty()) {
|
||||
throw BadRequestException("В таблице не найдено ни одного студента")
|
||||
}
|
||||
@@ -119,15 +128,21 @@ class StudentImportParser {
|
||||
|
||||
private fun isStudentHeader(value: String): Boolean {
|
||||
val normalized = value.lowercase().replace("\u00a0", " ").trim()
|
||||
return normalized.startsWith("студен") || normalized.startsWith("студ.") ||
|
||||
normalized.startsWith("зачёт") || normalized.startsWith("зачет")
|
||||
return normalized.startsWith("студен") ||
|
||||
normalized.startsWith("студ.") ||
|
||||
normalized.startsWith("зачёт") ||
|
||||
normalized.startsWith("зачет")
|
||||
}
|
||||
|
||||
private fun mapColumns(header: List<String>): Map<String, Int> {
|
||||
val result = mutableMapOf<String, Int>()
|
||||
header.forEachIndexed { index, raw ->
|
||||
val value = raw.lowercase().replace("\u00a0", " ").trim()
|
||||
fun putIfAbsent(key: String, vararg prefixes: String) {
|
||||
|
||||
fun putIfAbsent(
|
||||
key: String,
|
||||
vararg prefixes: String,
|
||||
) {
|
||||
if (key !in result && prefixes.any { value.startsWith(it) }) {
|
||||
result[key] = index
|
||||
}
|
||||
@@ -142,10 +157,12 @@ class StudentImportParser {
|
||||
return result
|
||||
}
|
||||
|
||||
private fun normalizeNumber(value: String): String =
|
||||
if (value.endsWith(".0")) value.dropLast(2) else value
|
||||
private fun normalizeNumber(value: String): String = if (value.endsWith(".0")) value.dropLast(2) else value
|
||||
|
||||
private fun groupNameFrom(filename: String, rows: List<List<String>>): String {
|
||||
private fun groupNameFrom(
|
||||
filename: String,
|
||||
rows: List<List<String>>,
|
||||
): String {
|
||||
val groupRegex = Regex("[А-ЯA-Z][А-ЯA-Za-z]*[- ]?\\d{1,4}[А-ЯA-Za-z]*")
|
||||
for (row in rows.take(12)) {
|
||||
for (cell in row) {
|
||||
|
||||
@@ -17,17 +17,19 @@ import ru.ysu.lms.security.JwtProperties
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(JwtProperties::class)
|
||||
class JwtConfig(private val keyStore: JwtKeyStore) {
|
||||
class JwtConfig(
|
||||
private val keyStore: JwtKeyStore,
|
||||
) {
|
||||
@Bean
|
||||
fun rsaKey(): RSAKey =
|
||||
RSAKey
|
||||
.Builder(keyStore.publicKey)
|
||||
.privateKey(keyStore.privateKey)
|
||||
.keyID("lms")
|
||||
.build()
|
||||
|
||||
@Bean
|
||||
fun rsaKey(): RSAKey = RSAKey.Builder(keyStore.publicKey)
|
||||
.privateKey(keyStore.privateKey)
|
||||
.keyID("lms")
|
||||
.build()
|
||||
|
||||
@Bean
|
||||
fun jwtEncoder(rsaKey: RSAKey): JwtEncoder =
|
||||
NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
|
||||
fun jwtEncoder(rsaKey: RSAKey): JwtEncoder = NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
|
||||
|
||||
@Bean
|
||||
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withPublicKey(keyStore.publicKey).build()
|
||||
|
||||
@@ -15,8 +15,9 @@ import java.util.Base64
|
||||
* in the `settings` table, so no private key ever lives in the repository.
|
||||
*/
|
||||
@Service
|
||||
class JwtKeyStore(private val settings: SettingRepository) {
|
||||
|
||||
class JwtKeyStore(
|
||||
private val settings: SettingRepository,
|
||||
) {
|
||||
lateinit var privateKey: RSAPrivateKey
|
||||
|
||||
lateinit var publicKey: RSAPublicKey
|
||||
@@ -38,7 +39,10 @@ class JwtKeyStore(private val settings: SettingRepository) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun toPem(label: String, der: ByteArray): String {
|
||||
private fun toPem(
|
||||
label: String,
|
||||
der: ByteArray,
|
||||
): String {
|
||||
val encoded = Base64.getMimeEncoder(64, "\n".toByteArray()).encodeToString(der)
|
||||
return "-----BEGIN $label-----\n$encoded\n-----END $label-----\n"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import org.springframework.security.web.SecurityFilterChain
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
http
|
||||
@@ -26,19 +25,19 @@ class SecurityConfig {
|
||||
.cors { it.disable() }
|
||||
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
||||
.authorizeHttpRequests { auth ->
|
||||
auth.requestMatchers(
|
||||
"/api/v1/auth/health",
|
||||
"/api/v1/auth/setup",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/jwks.json",
|
||||
"/actuator/**",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-ui/**",
|
||||
"/swagger-ui.html",
|
||||
).permitAll()
|
||||
auth
|
||||
.requestMatchers(
|
||||
"/api/v1/auth/health",
|
||||
"/api/v1/auth/setup",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/jwks.json",
|
||||
"/actuator/**",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-ui/**",
|
||||
"/swagger-ui.html",
|
||||
).permitAll()
|
||||
auth.anyRequest().authenticated()
|
||||
}
|
||||
.oauth2ResourceServer { resource ->
|
||||
}.oauth2ResourceServer { resource ->
|
||||
resource.jwt { jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()) }
|
||||
}
|
||||
return http.build()
|
||||
|
||||
@@ -8,20 +8,20 @@ import org.springframework.test.context.DynamicPropertySource
|
||||
import org.testcontainers.containers.PostgreSQLContainer
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import ru.ysu.lms.identity.auth.AuthService
|
||||
import ru.ysu.lms.common.UnauthorizedException
|
||||
import ru.ysu.lms.identity.auth.AuthService
|
||||
|
||||
@SpringBootTest
|
||||
@Testcontainers
|
||||
class IdentitySmokeTest {
|
||||
|
||||
companion object {
|
||||
@Container
|
||||
val pg = PostgreSQLContainer("postgres:18").apply {
|
||||
withDatabaseName("lms")
|
||||
withUsername("lms")
|
||||
withPassword("lms")
|
||||
}
|
||||
val pg =
|
||||
PostgreSQLContainer("postgres:18").apply {
|
||||
withDatabaseName("lms")
|
||||
withUsername("lms")
|
||||
withPassword("lms")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@DynamicPropertySource
|
||||
@@ -42,21 +42,31 @@ class IdentitySmokeTest {
|
||||
|
||||
@Test
|
||||
fun `setup creates admin and login works`() {
|
||||
val token = authService.setup(
|
||||
ru.ysu.lms.identity.auth.SetupRequest("admin", "test12345", "test12345")
|
||||
)
|
||||
val token =
|
||||
authService.setup(
|
||||
ru.ysu.lms.identity.auth
|
||||
.SetupRequest("admin", "test12345", "test12345"),
|
||||
)
|
||||
assert(token.token.isNotBlank()) { "token should not be blank" }
|
||||
assert(token.role == "ADMIN")
|
||||
|
||||
val login = authService.login(
|
||||
ru.ysu.lms.identity.auth.LoginRequest("admin", "test12345")
|
||||
)
|
||||
val login =
|
||||
authService.login(
|
||||
ru.ysu.lms.identity.auth
|
||||
.LoginRequest("admin", "test12345"),
|
||||
)
|
||||
assert(login.token.isNotBlank())
|
||||
|
||||
val ex = try {
|
||||
authService.login(ru.ysu.lms.identity.auth.LoginRequest("admin", "bad"))
|
||||
null
|
||||
} catch (e: UnauthorizedException) { e }
|
||||
val ex =
|
||||
try {
|
||||
authService.login(
|
||||
ru.ysu.lms.identity.auth
|
||||
.LoginRequest("admin", "bad"),
|
||||
)
|
||||
null
|
||||
} catch (e: UnauthorizedException) {
|
||||
e
|
||||
}
|
||||
assert(ex != null) { "bad password should throw" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,4 +23,4 @@ dependencies {
|
||||
|
||||
springBoot {
|
||||
mainClass.set("ru.ysu.lms.progress.ProgressServiceApplicationKt")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,16 +12,28 @@ class AcademicClient(
|
||||
) {
|
||||
private val client = RestClient.builder().baseUrl(baseUrl).build()
|
||||
|
||||
fun getLab(labId: String, token: String): LabInfo =
|
||||
client.get().uri("/api/v1/labs/{id}", labId)
|
||||
fun getLab(
|
||||
labId: String,
|
||||
token: String,
|
||||
): LabInfo =
|
||||
client
|
||||
.get()
|
||||
.uri("/api/v1/labs/{id}", labId)
|
||||
.header("Authorization", "Bearer $token")
|
||||
.retrieve().body(LabInfo::class.java)
|
||||
.retrieve()
|
||||
.body(LabInfo::class.java)
|
||||
?: throw NotFoundException("Лабораторная не найдена")
|
||||
|
||||
fun getLabSummary(labId: String, token: String): LabInfo =
|
||||
client.get().uri("/api/v1/labs/{id}", labId)
|
||||
fun getLabSummary(
|
||||
labId: String,
|
||||
token: String,
|
||||
): LabInfo =
|
||||
client
|
||||
.get()
|
||||
.uri("/api/v1/labs/{id}", labId)
|
||||
.header("Authorization", "Bearer $token")
|
||||
.retrieve().body(LabInfo::class.java)
|
||||
.retrieve()
|
||||
.body(LabInfo::class.java)
|
||||
?: throw NotFoundException("Лабораторная не найдена")
|
||||
}
|
||||
|
||||
@@ -31,16 +43,25 @@ class IdentityClient(
|
||||
) {
|
||||
private val client = RestClient.builder().baseUrl(baseUrl).build()
|
||||
|
||||
fun getGroup(groupId: String, token: String): GroupInfo =
|
||||
client.get().uri("/api/v1/groups/{id}", groupId)
|
||||
fun getGroup(
|
||||
groupId: String,
|
||||
token: String,
|
||||
): GroupInfo =
|
||||
client
|
||||
.get()
|
||||
.uri("/api/v1/groups/{id}", groupId)
|
||||
.header("Authorization", "Bearer $token")
|
||||
.retrieve().body(GroupInfo::class.java)
|
||||
.retrieve()
|
||||
.body(GroupInfo::class.java)
|
||||
?: throw NotFoundException("Группа не найдена")
|
||||
|
||||
fun listGroups(token: String): List<Map<String, Any>> {
|
||||
val type = object : ParameterizedTypeReference<List<Map<String, Any>>>() {}
|
||||
return client.get().uri("/api/v1/groups")
|
||||
return client
|
||||
.get()
|
||||
.uri("/api/v1/groups")
|
||||
.header("Authorization", "Bearer $token")
|
||||
.retrieve().body(type) ?: emptyList()
|
||||
.retrieve()
|
||||
.body(type) ?: emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.*
|
||||
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.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
|
||||
data class DefenseAssignPayload(val studentId: String, val labId: String, val assignmentMd: String)
|
||||
data class DefenseAssignPayload(
|
||||
val studentId: String,
|
||||
val labId: String,
|
||||
val assignmentMd: String,
|
||||
)
|
||||
|
||||
data class DefenseAdminDto(
|
||||
val id: String,
|
||||
@@ -22,27 +33,48 @@ data class DefenseAdminDto(
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/defenses")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
class DefenseAdminController(private val service: DefenseService) {
|
||||
|
||||
class DefenseAdminController(
|
||||
private val service: DefenseService,
|
||||
) {
|
||||
@PostMapping("/assign")
|
||||
fun assign(@RequestBody body: DefenseAssignPayload): DefenseDto =
|
||||
service.assign(UUID.fromString(body.studentId), UUID.fromString(body.labId), body.assignmentMd)
|
||||
fun assign(
|
||||
@RequestBody body: DefenseAssignPayload,
|
||||
): DefenseDto = service.assign(UUID.fromString(body.studentId), UUID.fromString(body.labId), body.assignmentMd)
|
||||
|
||||
@DeleteMapping("/assign")
|
||||
fun removeAssignment(@RequestParam studentId: String, @RequestParam labId: String) {
|
||||
fun removeAssignment(
|
||||
@RequestParam studentId: String,
|
||||
@RequestParam labId: String,
|
||||
) {
|
||||
service.resetByStudentLab(UUID.fromString(studentId), UUID.fromString(labId))
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
fun list(@RequestParam labId: UUID): List<DefenseAdminDto> =
|
||||
fun list(
|
||||
@RequestParam labId: UUID,
|
||||
): List<DefenseAdminDto> =
|
||||
service.listForLab(labId).map {
|
||||
DefenseAdminDto(it.id, it.studentId, it.labId, it.status, it.code, it.assignmentMd,
|
||||
it.startedAt, it.readyAt, it.readyReason, it.secondsLeft)
|
||||
DefenseAdminDto(
|
||||
it.id,
|
||||
it.studentId,
|
||||
it.labId,
|
||||
it.status,
|
||||
it.code,
|
||||
it.assignmentMd,
|
||||
it.startedAt,
|
||||
it.readyAt,
|
||||
it.readyReason,
|
||||
it.secondsLeft,
|
||||
)
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/issue-code")
|
||||
fun issueCode(@PathVariable id: UUID): Map<String, String> = mapOf("code" to service.issueCode(id))
|
||||
fun issueCode(
|
||||
@PathVariable id: UUID,
|
||||
): Map<String, String> = mapOf("code" to service.issueCode(id))
|
||||
|
||||
@PostMapping("/{id}/reset")
|
||||
fun reset(@PathVariable id: UUID) = service.reset(id)
|
||||
fun reset(
|
||||
@PathVariable id: UUID,
|
||||
) = service.reset(id)
|
||||
}
|
||||
|
||||
@@ -2,18 +2,27 @@ package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.web.bind.annotation.*
|
||||
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.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/me/defenses")
|
||||
class DefenseMyController(private val service: DefenseService) {
|
||||
|
||||
data class StartByCodePayload(val code: String)
|
||||
class DefenseMyController(
|
||||
private val service: DefenseService,
|
||||
) {
|
||||
data class StartByCodePayload(
|
||||
val code: String,
|
||||
)
|
||||
|
||||
@GetMapping
|
||||
fun my(@AuthenticationPrincipal jwt: Jwt): List<DefenseDto> =
|
||||
service.mySessions(UUID.fromString(jwt.subject)).map { hideCode(it) }
|
||||
fun my(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<DefenseDto> = service.mySessions(UUID.fromString(jwt.subject)).map { hideCode(it) }
|
||||
|
||||
@GetMapping("/{labId}")
|
||||
fun one(
|
||||
@@ -21,8 +30,7 @@ class DefenseMyController(private val service: DefenseService) {
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): DefenseDto? = service.mySession(UUID.fromString(jwt.subject), labId)?.let { hideCode(it) }
|
||||
|
||||
private fun hideCode(d: DefenseDto): DefenseDto =
|
||||
if (d.status == DefenseStatus.waiting.name) d.copy(code = null) else d
|
||||
private fun hideCode(d: DefenseDto): DefenseDto = if (d.status == DefenseStatus.waiting.name) d.copy(code = null) else d
|
||||
|
||||
@PostMapping("/start")
|
||||
fun start(
|
||||
|
||||
@@ -31,7 +31,6 @@ data class DefenseDto(
|
||||
class DefenseService(
|
||||
private val sessions: DefenseSessionRepository,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
val DURATION: Duration = Duration.ofMinutes(30)
|
||||
private val random = SecureRandom()
|
||||
@@ -55,7 +54,11 @@ class DefenseService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun assign(studentId: UUID, labId: UUID, assignmentMd: String): DefenseDto {
|
||||
fun assign(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
assignmentMd: String,
|
||||
): DefenseDto {
|
||||
val existing = sessions.findByStudentIdAndLabId(studentId, labId)
|
||||
val s = existing ?: DefenseSession(studentId = studentId, labId = labId)
|
||||
// смена задания сбрасывает активную попытку защиты
|
||||
@@ -106,10 +109,14 @@ class DefenseService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun startByCode(studentId: UUID, code: String): DefenseDto {
|
||||
fun startByCode(
|
||||
studentId: UUID,
|
||||
code: String,
|
||||
): DefenseDto {
|
||||
syncTimeouts()
|
||||
val s = sessions.findByCode(code.trim())
|
||||
?: throw BadRequestException("Код не найден")
|
||||
val s =
|
||||
sessions.findByCode(code.trim())
|
||||
?: throw BadRequestException("Код не найден")
|
||||
if (s.studentId != studentId) throw ForbiddenException("Этот код выдан другому студенту")
|
||||
if (s.status != DefenseStatus.waiting.name) throw BadRequestException("Защита по этому коду уже начата")
|
||||
s.status = DefenseStatus.started.name
|
||||
@@ -118,7 +125,10 @@ class DefenseService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun finish(studentId: UUID, sessionId: UUID): DefenseDto {
|
||||
fun finish(
|
||||
studentId: UUID,
|
||||
sessionId: UUID,
|
||||
): DefenseDto {
|
||||
val s = sessions.findById(sessionId).orElseThrow { NotFoundException("Сессия не найдена") }
|
||||
if (s.studentId != studentId) throw ForbiddenException("Не ваша сессия")
|
||||
if (s.status != DefenseStatus.started.name) throw BadRequestException("Защита ещё не начата")
|
||||
@@ -135,13 +145,19 @@ class DefenseService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun mySession(studentId: UUID, labId: UUID): DefenseDto? {
|
||||
fun mySession(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): DefenseDto? {
|
||||
syncTimeouts()
|
||||
return sessions.findByStudentIdAndLabId(studentId, labId)?.let { toDto(it) }
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun resetByStudentLab(studentId: UUID, labId: UUID) {
|
||||
fun resetByStudentLab(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
) {
|
||||
val s = sessions.findByStudentIdAndLabId(studentId, labId) ?: return
|
||||
s.status = DefenseStatus.waiting.name
|
||||
s.code = null
|
||||
@@ -152,11 +168,18 @@ class DefenseService(
|
||||
}
|
||||
|
||||
private fun toDto(s: DefenseSession): DefenseDto {
|
||||
val left = if (s.status == DefenseStatus.started.name) {
|
||||
val started = s.startedAt ?: Instant.now()
|
||||
val remain = java.time.Duration.between(Instant.now(), started.plus(DURATION)).seconds.coerceAtLeast(0)
|
||||
remain
|
||||
} else null
|
||||
val left =
|
||||
if (s.status == DefenseStatus.started.name) {
|
||||
val started = s.startedAt ?: Instant.now()
|
||||
val remain =
|
||||
java.time.Duration
|
||||
.between(Instant.now(), started.plus(DURATION))
|
||||
.seconds
|
||||
.coerceAtLeast(0)
|
||||
remain
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return DefenseDto(
|
||||
id = s.id.toString(),
|
||||
studentId = s.studentId.toString(),
|
||||
|
||||
@@ -136,8 +136,15 @@ data class GradeSetRequest(
|
||||
val comments: List<CommentInput> = emptyList(),
|
||||
)
|
||||
|
||||
data class CommentInput(val text: String, val tone: String = "neutral")
|
||||
data class CommentOut(val text: String, val tone: String)
|
||||
data class CommentInput(
|
||||
val text: String,
|
||||
val tone: String = "neutral",
|
||||
)
|
||||
|
||||
data class CommentOut(
|
||||
val text: String,
|
||||
val tone: String,
|
||||
)
|
||||
|
||||
data class GradeResponse(
|
||||
val studentId: String,
|
||||
@@ -153,4 +160,4 @@ data class SubmissionDto(
|
||||
val labId: String,
|
||||
val createdAt: String,
|
||||
val files: List<String>,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3,29 +3,33 @@ package ru.ysu.lms.progress
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.progress.domain.GradePublication
|
||||
import ru.ysu.lms.progress.domain.GradePublicationKey
|
||||
import ru.ysu.lms.progress.repo.GradePublicationRepository
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class GradePublicationService(private val repo: GradePublicationRepository) {
|
||||
|
||||
class GradePublicationService(
|
||||
private val repo: GradePublicationRepository,
|
||||
) {
|
||||
@Transactional
|
||||
fun setPublished(groupId: UUID, labId: UUID, published: Boolean) {
|
||||
fun setPublished(
|
||||
groupId: UUID,
|
||||
labId: UUID,
|
||||
published: Boolean,
|
||||
) {
|
||||
val existing = repo.findByLabIdAndGroupId(labId, groupId)
|
||||
if (published && existing == null) repo.save(GradePublication(groupId, labId))
|
||||
if (!published && existing != null) repo.delete(existing)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun isPublished(groupId: UUID, labId: UUID): Boolean =
|
||||
repo.findByLabIdAndGroupId(labId, groupId) != null
|
||||
fun isPublished(
|
||||
groupId: UUID,
|
||||
labId: UUID,
|
||||
): Boolean = repo.findByLabIdAndGroupId(labId, groupId) != null
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun publishedGroupIdsForLab(labId: UUID): List<String> =
|
||||
repo.findByLabId(labId).map { it.groupId.toString() }
|
||||
fun publishedGroupIdsForLab(labId: UUID): List<String> = repo.findByLabId(labId).map { it.groupId.toString() }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun publishedLabIdsForGroup(groupId: UUID): List<String> =
|
||||
repo.findByGroupId(groupId).map { it.labId.toString() }
|
||||
fun publishedLabIdsForGroup(groupId: UUID): List<String> = repo.findByGroupId(groupId).map { it.labId.toString() }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,14 @@ import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.web.bind.annotation.*
|
||||
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.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RequestPart
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
@@ -21,9 +28,10 @@ class ProgressController(
|
||||
private val submissions: SubmissionService,
|
||||
private val academic: AcademicClient,
|
||||
) {
|
||||
|
||||
@GetMapping("/me/assignments")
|
||||
fun meAssignments(@AuthenticationPrincipal jwt: Jwt): List<MeAssignmentResponse> {
|
||||
fun meAssignments(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<MeAssignmentResponse> {
|
||||
val sid = UUID.fromString(jwt.subject)
|
||||
return service.getStudentAssignments(sid).mapNotNull { va ->
|
||||
service.getStudentAssignment(sid, va.labId, jwt.tokenValue)
|
||||
@@ -31,9 +39,9 @@ class ProgressController(
|
||||
}
|
||||
|
||||
@GetMapping("/me/labs")
|
||||
fun meLabs(@AuthenticationPrincipal jwt: Jwt): List<Map<String, Any>> {
|
||||
return listOf()
|
||||
}
|
||||
fun meLabs(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<Map<String, Any>> = listOf()
|
||||
|
||||
@PostMapping("/levels")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@@ -44,7 +52,9 @@ class ProgressController(
|
||||
|
||||
@GetMapping("/levels")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun getLevels(@RequestParam labId: UUID): List<LevelAssignmentResponse> =
|
||||
fun getLevels(
|
||||
@RequestParam labId: UUID,
|
||||
): List<LevelAssignmentResponse> =
|
||||
service.getLevelsForLab(labId).map { sll ->
|
||||
LevelAssignmentResponse(
|
||||
studentId = sll.studentId.toString(),
|
||||
@@ -77,7 +87,9 @@ class ProgressController(
|
||||
|
||||
@PostMapping("/grades")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun setGrade(@Valid @RequestBody body: GradeSetRequest): GradeResponse = service.setGrade(body)
|
||||
fun setGrade(
|
||||
@Valid @RequestBody body: GradeSetRequest,
|
||||
): GradeResponse = service.setGrade(body)
|
||||
|
||||
@GetMapping("/grades")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@@ -85,19 +97,26 @@ class ProgressController(
|
||||
|
||||
@PostMapping("/grades/publish")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun publishGrades(@RequestParam labId: java.util.UUID, @RequestParam groupId: java.util.UUID, @RequestParam published: Boolean) {
|
||||
fun publishGrades(
|
||||
@RequestParam labId: java.util.UUID,
|
||||
@RequestParam groupId: java.util.UUID,
|
||||
@RequestParam published: Boolean,
|
||||
) {
|
||||
publications.setPublished(groupId, labId, published)
|
||||
}
|
||||
|
||||
@GetMapping("/grades/published")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun gradesPublished(@RequestParam("labId") labId: java.util.UUID, @RequestParam groupId: java.util.UUID): Boolean =
|
||||
publications.isPublished(groupId, labId)
|
||||
fun gradesPublished(
|
||||
@RequestParam("labId") labId: java.util.UUID,
|
||||
@RequestParam groupId: java.util.UUID,
|
||||
): Boolean = publications.isPublished(groupId, labId)
|
||||
|
||||
@GetMapping("/grades/publications")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
fun groupPublications(@RequestParam groupId: java.util.UUID): List<String> =
|
||||
publications.publishedLabIdsForGroup(groupId)
|
||||
fun groupPublications(
|
||||
@RequestParam groupId: java.util.UUID,
|
||||
): List<String> = publications.publishedLabIdsForGroup(groupId)
|
||||
|
||||
@GetMapping("/me/grades")
|
||||
fun myGrades(
|
||||
@@ -111,7 +130,9 @@ class ProgressController(
|
||||
|
||||
@GetMapping("/comment-templates")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun suggestComments(@RequestParam q: String): List<CommentOut> = service.suggestComments(q)
|
||||
fun suggestComments(
|
||||
@RequestParam q: String,
|
||||
): List<CommentOut> = service.suggestComments(q)
|
||||
|
||||
@PostMapping("/me/submissions/{labId}")
|
||||
fun submit(
|
||||
@@ -131,7 +152,10 @@ class ProgressController(
|
||||
): List<SubmissionDto> = submissions.mySubmissions(UUID.fromString(jwt.subject), labId)
|
||||
|
||||
@GetMapping("/me/submissions/{id}/download")
|
||||
fun downloadMine(@PathVariable id: UUID, @AuthenticationPrincipal jwt: Jwt): ResponseEntity<ByteArray> {
|
||||
fun downloadMine(
|
||||
@PathVariable id: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): ResponseEntity<ByteArray> {
|
||||
val sid = UUID.fromString(jwt.subject)
|
||||
val (bytes, name) = submissions.download(sid, id, sid, isAdmin = false)
|
||||
return attachment(bytes, name)
|
||||
@@ -139,22 +163,29 @@ class ProgressController(
|
||||
|
||||
@GetMapping("/submissions")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun labSubmissions(@RequestParam labId: UUID): List<SubmissionDto> =
|
||||
submissions.latestPerStudent(labId)
|
||||
fun labSubmissions(
|
||||
@RequestParam labId: UUID,
|
||||
): List<SubmissionDto> = submissions.latestPerStudent(labId)
|
||||
|
||||
@GetMapping("/submissions/{id}/download")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun downloadForAdmin(@PathVariable id: UUID, @AuthenticationPrincipal jwt: Jwt): ResponseEntity<ByteArray> {
|
||||
fun downloadForAdmin(
|
||||
@PathVariable id: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): ResponseEntity<ByteArray> {
|
||||
val (bytes, name) = submissions.download(UUID.fromString(jwt.subject), id, null, isAdmin = true)
|
||||
return attachment(bytes, name)
|
||||
}
|
||||
|
||||
private fun attachment(bytes: ByteArray, name: String): ResponseEntity<ByteArray> =
|
||||
ResponseEntity.ok()
|
||||
private fun attachment(
|
||||
bytes: ByteArray,
|
||||
name: String,
|
||||
): ResponseEntity<ByteArray> =
|
||||
ResponseEntity
|
||||
.ok()
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename*=UTF-8''${URLEncoder.encode(name, StandardCharsets.UTF_8)}",
|
||||
)
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
).contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.body(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@ import ru.ysu.lms.progress.domain.GradeComment
|
||||
import ru.ysu.lms.progress.domain.LabGrade
|
||||
import ru.ysu.lms.progress.domain.LabGradeKey
|
||||
import ru.ysu.lms.progress.domain.StudentLabLevel
|
||||
import ru.ysu.lms.progress.domain.StudentLabLevelKey
|
||||
import ru.ysu.lms.progress.domain.VariantAssignment
|
||||
import ru.ysu.lms.progress.repo.CommentTemplateRepository
|
||||
import ru.ysu.lms.progress.repo.GradeCommentRepository
|
||||
import ru.ysu.lms.progress.repo.LabGradeRepository
|
||||
import ru.ysu.lms.progress.repo.StudentLabLevelRepository
|
||||
import ru.ysu.lms.progress.repo.VariantAssignmentRepository
|
||||
import ru.ysu.lms.progress.domain.StudentLabLevelKey
|
||||
import java.math.BigDecimal
|
||||
import java.util.UUID
|
||||
|
||||
@@ -28,9 +28,11 @@ class ProgressService(
|
||||
private val academic: AcademicClient,
|
||||
private val identity: IdentityClient,
|
||||
) {
|
||||
|
||||
@Transactional
|
||||
fun setLevel(request: LevelAssignmentRequest, token: String): LevelAssignmentResponse {
|
||||
fun setLevel(
|
||||
request: LevelAssignmentRequest,
|
||||
token: String,
|
||||
): LevelAssignmentResponse {
|
||||
val sid = UUID.fromString(request.studentId)
|
||||
val lid = UUID.fromString(request.labId)
|
||||
val key = StudentLabLevelKey(sid, lid)
|
||||
@@ -46,20 +48,27 @@ class ProgressService(
|
||||
val level = lab?.levels?.firstOrNull { it.id == request.labLevelId }
|
||||
if (level != null && level.variants.isNotEmpty()) {
|
||||
val assignment = assignments.findByStudentIdAndLabId(sid, lid)
|
||||
val currentInLevel = assignment?.variantId?.toString()
|
||||
?.let { vid -> level.variants.any { it.id == vid } } == true
|
||||
val currentInLevel =
|
||||
assignment
|
||||
?.variantId
|
||||
?.toString()
|
||||
?.let { vid -> level.variants.any { it.id == vid } } == true
|
||||
if (!currentInLevel) {
|
||||
val used = assignments.findByLabId(lid)
|
||||
.map { it.variantId.toString() }
|
||||
.filter { it != assignment?.variantId?.toString() }
|
||||
.toMutableSet()
|
||||
val used =
|
||||
assignments
|
||||
.findByLabId(lid)
|
||||
.map { it.variantId.toString() }
|
||||
.filter { it != assignment?.variantId?.toString() }
|
||||
.toMutableSet()
|
||||
val pool = level.variants.map { it.id }
|
||||
val chosen = pool.filter { it !in used }.shuffled().firstOrNull()
|
||||
?: pool.shuffled().first()
|
||||
val target = (assignment ?: VariantAssignment(studentId = sid, labId = lid)).apply {
|
||||
variantId = UUID.fromString(chosen)
|
||||
manual = false
|
||||
}
|
||||
val chosen =
|
||||
pool.filter { it !in used }.shuffled().firstOrNull()
|
||||
?: pool.shuffled().first()
|
||||
val target =
|
||||
(assignment ?: VariantAssignment(studentId = sid, labId = lid)).apply {
|
||||
variantId = UUID.fromString(chosen)
|
||||
manual = false
|
||||
}
|
||||
assignments.save(target)
|
||||
}
|
||||
}
|
||||
@@ -72,23 +81,34 @@ class ProgressService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getLevel(studentId: UUID, labId: UUID): StudentLabLevel? =
|
||||
levels.findByStudentIdAndLabId(studentId, labId)
|
||||
fun getLevel(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): StudentLabLevel? = levels.findByStudentIdAndLabId(studentId, labId)
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getLevelsForLab(labId: UUID): List<StudentLabLevel> =
|
||||
levels.findByLabId(labId)
|
||||
fun getLevelsForLab(labId: UUID): List<StudentLabLevel> = levels.findByLabId(labId)
|
||||
|
||||
@Transactional
|
||||
fun distribute(request: DistributeRequest, token: String): DistributeResult {
|
||||
val labIds = request.labIds.orEmpty().ifEmpty { listOfNotNull(request.labId) }
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
fun distribute(
|
||||
request: DistributeRequest,
|
||||
token: String,
|
||||
): DistributeResult {
|
||||
val labIds =
|
||||
request.labIds
|
||||
.orEmpty()
|
||||
.ifEmpty { listOfNotNull(request.labId) }
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
if (labIds.isEmpty()) throw BadRequestException("Не указана ни одна лабораторная")
|
||||
|
||||
val force = request.force == true
|
||||
val groupFilter = request.groupIds.orEmpty().filter { it.isNotBlank() }.toSet()
|
||||
val groupFilter =
|
||||
request.groupIds
|
||||
.orEmpty()
|
||||
.filter { it.isNotBlank() }
|
||||
.toSet()
|
||||
val studentIds = mutableListOf<UUID>()
|
||||
for (g in identity.listGroups(token)) {
|
||||
val gid = g["id"] as String
|
||||
@@ -111,14 +131,16 @@ class ProgressService(
|
||||
// force сбрасывает авто-назначения только у выбранных студентов;
|
||||
// закреплённые вручную варианты остаются как есть.
|
||||
if (force) {
|
||||
assignments.findByLabId(labId)
|
||||
assignments
|
||||
.findByLabId(labId)
|
||||
.filter { it.studentId in allStudents && !it.manual }
|
||||
.forEach { assignments.delete(it) }
|
||||
}
|
||||
|
||||
val kept = mutableMapOf<UUID, VariantAssignment>()
|
||||
assignedTotal += assignments.findByLabId(labId).count { it.studentId in allStudents }
|
||||
assignments.findByLabId(labId)
|
||||
assignments
|
||||
.findByLabId(labId)
|
||||
.filter { it.studentId in allStudents }
|
||||
.forEach { kept[it.studentId] = it }
|
||||
|
||||
@@ -132,17 +154,23 @@ class ProgressService(
|
||||
val defaultLevel = lab.levels.firstOrNull()?.id
|
||||
|
||||
if (toAssign.isNotEmpty() && poolsByLevel.isNotEmpty()) {
|
||||
val usage = assignments.findByLabId(labId)
|
||||
.groupingBy { it.variantId.toString() }
|
||||
.eachCount()
|
||||
.toMutableMap()
|
||||
val usage =
|
||||
assignments
|
||||
.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() }
|
||||
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()
|
||||
@@ -174,23 +202,26 @@ class ProgressService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> =
|
||||
assignments.findByLabId(labId)
|
||||
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> = assignments.findByLabId(labId)
|
||||
|
||||
@Transactional
|
||||
fun assign(request: AssignRequest): VariantAssignment {
|
||||
val sid = UUID.fromString(request.studentId)
|
||||
val lid = UUID.fromString(request.labId)
|
||||
val existing = assignments.findByStudentIdAndLabId(sid, lid)
|
||||
val entity = (existing ?: VariantAssignment(studentId = sid, labId = lid)).apply {
|
||||
variantId = UUID.fromString(request.variantId)
|
||||
manual = true
|
||||
}
|
||||
val entity =
|
||||
(existing ?: VariantAssignment(studentId = sid, labId = lid)).apply {
|
||||
variantId = UUID.fromString(request.variantId)
|
||||
manual = true
|
||||
}
|
||||
return assignments.save(entity)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun assignWithInfo(request: AssignRequest, token: String): VariantAssignmentResponse {
|
||||
fun assignWithInfo(
|
||||
request: AssignRequest,
|
||||
token: String,
|
||||
): VariantAssignmentResponse {
|
||||
assign(request)
|
||||
val lab = academic.getLab(request.labId, token)
|
||||
val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == request.variantId }
|
||||
@@ -204,7 +235,10 @@ class ProgressService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun assignmentsForLabEnriched(labId: UUID, token: String): List<VariantAssignmentResponse> {
|
||||
fun assignmentsForLabEnriched(
|
||||
labId: UUID,
|
||||
token: String,
|
||||
): List<VariantAssignmentResponse> {
|
||||
val lab = academic.getLab(labId.toString(), token)
|
||||
val variants = lab.levels.flatMap { it.variants }.associateBy { it.id }
|
||||
return assignments.findByLabId(labId).map { va ->
|
||||
@@ -220,8 +254,7 @@ class ProgressService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> =
|
||||
assignments.findByStudentId(studentId)
|
||||
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> = assignments.findByStudentId(studentId)
|
||||
|
||||
@Transactional
|
||||
fun setGrade(request: GradeSetRequest): GradeResponse {
|
||||
@@ -235,11 +268,19 @@ class ProgressService(
|
||||
grade.score = request.score
|
||||
grades.save(grade)
|
||||
replaceComments(sid, lid, request.comments)
|
||||
return GradeResponse(request.studentId, request.labId, request.score,
|
||||
commentsOf(sid, lid).map { CommentOut(it.text, it.tone) })
|
||||
return GradeResponse(
|
||||
request.studentId,
|
||||
request.labId,
|
||||
request.score,
|
||||
commentsOf(sid, lid).map { CommentOut(it.text, it.tone) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun replaceComments(sid: UUID, lid: UUID, inputs: List<CommentInput>) {
|
||||
private fun replaceComments(
|
||||
sid: UUID,
|
||||
lid: UUID,
|
||||
inputs: List<CommentInput>,
|
||||
) {
|
||||
val existing = gradeComments.findByStudentIdAndLabId(sid, lid).map { it.text + "|" + it.tone }.toSet()
|
||||
val incoming = inputs.filter { it.text.isNotBlank() }.map { it.text.trim() to normalizeTone(it.tone) }
|
||||
if (existing.toSet() == incoming.toSet()) return
|
||||
@@ -247,23 +288,33 @@ class ProgressService(
|
||||
for ((text, tone) in incoming) {
|
||||
gradeComments.save(GradeComment(studentId = sid, labId = lid, text = text, tone = tone))
|
||||
val t = templates.findByText(text)
|
||||
if (t == null) templates.save(CommentTemplate(text = text, tone = tone, useCount = 1))
|
||||
else { t.useCount += 1; t.tone = tone; templates.save(t) }
|
||||
if (t == null) {
|
||||
templates.save(CommentTemplate(text = text, tone = tone, useCount = 1))
|
||||
} else {
|
||||
t.useCount += 1
|
||||
t.tone = tone
|
||||
templates.save(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeTone(tone: String): String = when (tone.lowercase()) {
|
||||
"positive", "положительный", "позитивный", "pos" -> "positive"
|
||||
"negative", "негативный", "отрицательный", "neg" -> "negative"
|
||||
else -> "neutral"
|
||||
}
|
||||
private fun normalizeTone(tone: String): String =
|
||||
when (tone.lowercase()) {
|
||||
"positive", "положительный", "позитивный", "pos" -> "positive"
|
||||
"negative", "негативный", "отрицательный", "neg" -> "negative"
|
||||
else -> "neutral"
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun commentsOf(sid: UUID, lid: UUID): List<GradeComment> = gradeComments.findByStudentIdAndLabId(sid, lid)
|
||||
fun commentsOf(
|
||||
sid: UUID,
|
||||
lid: UUID,
|
||||
): List<GradeComment> = gradeComments.findByStudentIdAndLabId(sid, lid)
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun suggestComments(query: String): List<CommentOut> =
|
||||
templates.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim())
|
||||
templates
|
||||
.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim())
|
||||
.map { CommentOut(it.text, it.tone) }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -271,8 +322,10 @@ class ProgressService(
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun listGrades(): List<GradeResponse> {
|
||||
val byGrade = gradeComments.findAll()
|
||||
.groupBy { it.studentId to it.labId }
|
||||
val byGrade =
|
||||
gradeComments
|
||||
.findAll()
|
||||
.groupBy { it.studentId to it.labId }
|
||||
return grades.findAllBy().map {
|
||||
GradeResponse(
|
||||
studentId = it.studentId.toString(),
|
||||
@@ -284,12 +337,19 @@ class ProgressService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getStudentAssignment(studentId: UUID, labId: UUID, token: String): MeAssignmentResponse? {
|
||||
fun getStudentAssignment(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
token: String,
|
||||
): MeAssignmentResponse? {
|
||||
val va = assignments.findByStudentIdAndLabId(studentId, labId) ?: return null
|
||||
return enrich(va, token)
|
||||
}
|
||||
|
||||
private fun enrich(va: VariantAssignment, token: String): MeAssignmentResponse? {
|
||||
private fun enrich(
|
||||
va: VariantAssignment,
|
||||
token: String,
|
||||
): MeAssignmentResponse? {
|
||||
val lab = runCatching { academic.getLab(va.labId.toString(), token) }.getOrNull() ?: return null
|
||||
val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == va.variantId.toString() }
|
||||
val level = lab.levels.firstOrNull { lv -> lv.variants.any { it.id == va.variantId.toString() } }
|
||||
@@ -306,4 +366,4 @@ class ProgressService(
|
||||
pointsMax = lab.pointsMax,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@ package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import ru.ysu.lms.security.LmsSecurityAutoConfiguration
|
||||
|
||||
@SpringBootApplication
|
||||
class ProgressServiceApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<ProgressServiceApplication>(*args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ class SubmissionService(
|
||||
private val storage: SubmissionStorage,
|
||||
private val academic: AcademicClient,
|
||||
) {
|
||||
|
||||
@Transactional
|
||||
fun submit(
|
||||
studentId: UUID,
|
||||
@@ -29,8 +28,9 @@ class SubmissionService(
|
||||
if (files.isEmpty() || files.all { it.isEmpty }) {
|
||||
throw BadRequestException("Приложите файлы или архив")
|
||||
}
|
||||
val lab = runCatching { academic.getLabSummary(labId.toString(), token) }.getOrNull()
|
||||
?: throw NotFoundException("Лабораторная не найдена")
|
||||
val lab =
|
||||
runCatching { academic.getLabSummary(labId.toString(), token) }.getOrNull()
|
||||
?: throw NotFoundException("Лабораторная не найдена")
|
||||
val deadline = lab.deadlineAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
||||
if (deadline != null && Instant.now().isAfter(deadline)) {
|
||||
throw BadRequestException("Срок сдачи истёк")
|
||||
@@ -52,12 +52,22 @@ class SubmissionService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun mySubmissions(studentId: UUID, labId: UUID): List<SubmissionDto> =
|
||||
submissions.findByStudentIdAndLabIdOrderBySeqDesc(studentId, labId).map { it to filesOf(it) }
|
||||
fun mySubmissions(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): List<SubmissionDto> =
|
||||
submissions
|
||||
.findByStudentIdAndLabIdOrderBySeqDesc(studentId, labId)
|
||||
.map { it to filesOf(it) }
|
||||
.map { (e, fs) -> toDto(e, fs) }
|
||||
|
||||
@Transactional
|
||||
fun download(studentId: UUID, submissionId: UUID, callerStudentId: UUID?, isAdmin: Boolean): Pair<ByteArray, String> {
|
||||
fun download(
|
||||
studentId: UUID,
|
||||
submissionId: UUID,
|
||||
callerStudentId: UUID?,
|
||||
isAdmin: Boolean,
|
||||
): Pair<ByteArray, String> {
|
||||
val s = submissions.findById(submissionId).orElseThrow { NotFoundException("Сдача не найдена") }
|
||||
if (!isAdmin && (callerStudentId == null || s.studentId != callerStudentId)) {
|
||||
throw ForbiddenException("Нет доступа к этой сдаче")
|
||||
@@ -81,23 +91,29 @@ class SubmissionService(
|
||||
/** Файлы с "исходниковым" расширением, содержимое которых — XML-файл проекта (VS/IDE). */
|
||||
private val sourceExtensions = setOf("c", "h", "cpp", "hpp", "cc", "cxx", "txt")
|
||||
|
||||
private fun looksLikeProjectFile(name: String, bytes: ByteArray): Boolean {
|
||||
private fun looksLikeProjectFile(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
): Boolean {
|
||||
val ext = name.substringAfterLast('.', "").lowercase()
|
||||
if (ext !in sourceExtensions) return false
|
||||
val head = String(bytes, 0, minOf(bytes.size, 512), Charsets.UTF_8)
|
||||
.removePrefix("\uFEFF")
|
||||
.trimStart()
|
||||
.lowercase()
|
||||
val head =
|
||||
String(bytes, 0, minOf(bytes.size, 512), Charsets.UTF_8)
|
||||
.removePrefix("\uFEFF")
|
||||
.trimStart()
|
||||
.lowercase()
|
||||
return head.startsWith("<?xml") ||
|
||||
head.startsWith("<solution") ||
|
||||
head.startsWith("<project") ||
|
||||
head.startsWith("<configurations")
|
||||
}
|
||||
|
||||
private fun filesOf(s: Submission): List<String> =
|
||||
storage.listFiles(storage.dirFor(s.studentId, s.labId, s.seq))
|
||||
private fun filesOf(s: Submission): List<String> = storage.listFiles(storage.dirFor(s.studentId, s.labId, s.seq))
|
||||
|
||||
private fun toDto(s: Submission, files: List<String>): SubmissionDto =
|
||||
private fun toDto(
|
||||
s: Submission,
|
||||
files: List<String>,
|
||||
): SubmissionDto =
|
||||
SubmissionDto(
|
||||
id = s.id.toString(),
|
||||
seq = s.seq,
|
||||
|
||||
@@ -9,14 +9,29 @@ import java.util.UUID
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
data class StoredSubmission(val dir: Path, val files: List<String>)
|
||||
data class StoredSubmission(
|
||||
val dir: Path,
|
||||
val files: List<String>,
|
||||
)
|
||||
|
||||
@Component
|
||||
class SubmissionStorage(@Value("\${lms.submissions.dir:data/submissions}") root: String) {
|
||||
class SubmissionStorage(
|
||||
@Value("\${lms.submissions.dir:data/submissions}") root: String,
|
||||
) {
|
||||
private val rootDir: Path = Path.of(root)
|
||||
|
||||
fun store(studentId: UUID, labId: UUID, seq: Int, files: List<Pair<String, ByteArray>>): StoredSubmission {
|
||||
val dir = rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString()).normalize()
|
||||
fun store(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
seq: Int,
|
||||
files: List<Pair<String, ByteArray>>,
|
||||
): StoredSubmission {
|
||||
val dir =
|
||||
rootDir
|
||||
.resolve(studentId.toString())
|
||||
.resolve(labId.toString())
|
||||
.resolve(seq.toString())
|
||||
.normalize()
|
||||
require(dir.startsWith(rootDir)) { "path escape blocked" }
|
||||
Files.createDirectories(dir)
|
||||
val names = mutableListOf<String>()
|
||||
@@ -46,13 +61,20 @@ class SubmissionStorage(@Value("\${lms.submissions.dir:data/submissions}") root:
|
||||
|
||||
fun listFiles(storedDir: Path): List<String> =
|
||||
if (Files.isDirectory(storedDir)) {
|
||||
Files.list(storedDir).map { it.fileName.toString() }.sorted().toList()
|
||||
Files
|
||||
.list(storedDir)
|
||||
.map { it.fileName.toString() }
|
||||
.sorted()
|
||||
.toList()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
fun dirFor(studentId: UUID, labId: UUID, seq: Int): Path =
|
||||
rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString())
|
||||
fun dirFor(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
seq: Int,
|
||||
): Path = rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString())
|
||||
|
||||
private fun sanitize(name: String): String {
|
||||
val base = name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "file" }
|
||||
|
||||
@@ -20,4 +20,4 @@ class DefenseSession(
|
||||
@Column(name = "started_at") var startedAt: Instant? = null,
|
||||
@Column(name = "ready_at") var readyAt: Instant? = null,
|
||||
@Column(name = "ready_reason") var readyReason: String? = null,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -23,4 +23,4 @@ class LabGrade(
|
||||
data class LabGradeKey(
|
||||
val studentId: UUID = UUID(0, 0),
|
||||
val labId: UUID = UUID(0, 0),
|
||||
) : Serializable
|
||||
) : Serializable
|
||||
|
||||
@@ -11,13 +11,13 @@ import java.util.UUID
|
||||
@Table(name = "student_lab_levels")
|
||||
@IdClass(StudentLabLevelKey::class)
|
||||
class StudentLabLevel(
|
||||
@Id @Column(nullable = false) var studentId: UUID = UUID(0,0),
|
||||
@Id @Column(nullable = false) var labId: UUID = UUID(0,0),
|
||||
@Column(nullable = false) var labLevelId: UUID = UUID(0,0),
|
||||
@Id @Column(nullable = false) var studentId: UUID = UUID(0, 0),
|
||||
@Id @Column(nullable = false) var labId: UUID = UUID(0, 0),
|
||||
@Column(nullable = false) var labLevelId: UUID = UUID(0, 0),
|
||||
@Column(nullable = false) var manual: Boolean = false,
|
||||
)
|
||||
|
||||
data class StudentLabLevelKey(
|
||||
val studentId: UUID = UUID(0,0),
|
||||
val labId: UUID = UUID(0,0),
|
||||
) : java.io.Serializable
|
||||
val studentId: UUID = UUID(0, 0),
|
||||
val labId: UUID = UUID(0, 0),
|
||||
) : java.io.Serializable
|
||||
|
||||
@@ -16,4 +16,4 @@ class Submission(
|
||||
@Column(nullable = false) var labId: UUID = UUID(0, 0),
|
||||
@Column(nullable = false) var seq: Int = 1,
|
||||
@Column(nullable = false) var createdAt: Instant = Instant.now(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -12,9 +12,9 @@ import java.util.UUID
|
||||
@Table(name = "variant_assignments")
|
||||
class VariantAssignment(
|
||||
@Id var id: UUID = Ids.v7(),
|
||||
@Column(nullable = false) var studentId: UUID = UUID(0,0),
|
||||
@Column(nullable = false) var labId: UUID = UUID(0,0),
|
||||
@Column(nullable = false) var variantId: UUID = UUID(0,0),
|
||||
@Column(nullable = false) var studentId: UUID = UUID(0, 0),
|
||||
@Column(nullable = false) var labId: UUID = UUID(0, 0),
|
||||
@Column(nullable = false) var variantId: UUID = UUID(0, 0),
|
||||
@Column(nullable = false) var manual: Boolean = false,
|
||||
@Column(nullable = false) var assignedAt: Instant = Instant.now(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -7,11 +7,20 @@ import java.util.UUID
|
||||
|
||||
interface CommentTemplateRepository : JpaRepository<CommentTemplate, UUID> {
|
||||
fun findByText(text: String): CommentTemplate?
|
||||
|
||||
fun findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(text: String): List<CommentTemplate>
|
||||
}
|
||||
|
||||
interface GradeCommentRepository : JpaRepository<GradeComment, UUID> {
|
||||
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): List<GradeComment>
|
||||
fun deleteByStudentIdAndLabId(studentId: UUID, labId: UUID)
|
||||
fun findByStudentIdAndLabId(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): List<GradeComment>
|
||||
|
||||
fun deleteByStudentIdAndLabId(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
)
|
||||
|
||||
fun findByStudentId(studentId: UUID): List<GradeComment>
|
||||
}
|
||||
|
||||
@@ -6,8 +6,15 @@ import java.util.UUID
|
||||
|
||||
interface DefenseSessionRepository : JpaRepository<DefenseSession, UUID> {
|
||||
fun findByLabIdOrderByStudentIdAsc(labId: UUID): List<DefenseSession>
|
||||
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): DefenseSession?
|
||||
|
||||
fun findByStudentIdAndLabId(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): DefenseSession?
|
||||
|
||||
fun findByStudentId(studentId: UUID): List<DefenseSession>
|
||||
|
||||
fun findByStatus(status: String): List<DefenseSession>
|
||||
|
||||
fun findByCode(code: String): DefenseSession?
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@ import ru.ysu.lms.progress.domain.GradePublicationKey
|
||||
import java.util.UUID
|
||||
|
||||
interface GradePublicationRepository : JpaRepository<GradePublication, GradePublicationKey> {
|
||||
fun findByLabIdAndGroupId(labId: UUID, groupId: UUID): GradePublication?
|
||||
fun findByLabIdAndGroupId(
|
||||
labId: UUID,
|
||||
groupId: UUID,
|
||||
): GradePublication?
|
||||
|
||||
fun findByLabId(labId: UUID): List<GradePublication>
|
||||
|
||||
fun findByGroupId(groupId: UUID): List<GradePublication>
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ import java.util.UUID
|
||||
|
||||
interface LabGradeRepository : JpaRepository<LabGrade, LabGradeKey> {
|
||||
fun findAllBy(): List<LabGrade>
|
||||
|
||||
fun findByStudentId(studentId: UUID): List<LabGrade>
|
||||
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): LabGrade?
|
||||
|
||||
fun findByStudentIdAndLabId(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): LabGrade?
|
||||
}
|
||||
|
||||
@@ -7,7 +7,13 @@ import java.util.UUID
|
||||
|
||||
interface StudentLabLevelRepository : JpaRepository<StudentLabLevel, StudentLabLevelKey> {
|
||||
fun findByLabId(labId: UUID): List<StudentLabLevel>
|
||||
|
||||
fun findByStudentId(studentId: UUID): List<StudentLabLevel>
|
||||
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): StudentLabLevel?
|
||||
|
||||
fun findByStudentIdAndLabId(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): StudentLabLevel?
|
||||
|
||||
fun deleteByLabId(labId: UUID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,16 @@ import java.util.UUID
|
||||
|
||||
interface SubmissionRepository : JpaRepository<Submission, UUID> {
|
||||
fun findByStudentIdOrderByCreatedAtDesc(studentId: UUID): List<Submission>
|
||||
fun findByStudentIdAndLabIdOrderBySeqDesc(studentId: UUID, labId: UUID): List<Submission>
|
||||
|
||||
fun findByStudentIdAndLabIdOrderBySeqDesc(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): List<Submission>
|
||||
|
||||
fun findByLabIdOrderByCreatedAtDesc(labId: UUID): List<Submission>
|
||||
fun findFirstByStudentIdAndLabIdOrderBySeqDesc(studentId: UUID, labId: UUID): Submission?
|
||||
}
|
||||
|
||||
fun findFirstByStudentIdAndLabIdOrderBySeqDesc(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): Submission?
|
||||
}
|
||||
|
||||
@@ -6,7 +6,13 @@ import java.util.UUID
|
||||
|
||||
interface VariantAssignmentRepository : JpaRepository<VariantAssignment, UUID> {
|
||||
fun findByStudentId(studentId: UUID): List<VariantAssignment>
|
||||
|
||||
fun findByLabId(labId: UUID): List<VariantAssignment>
|
||||
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): VariantAssignment?
|
||||
|
||||
fun findByStudentIdAndLabId(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
): VariantAssignment?
|
||||
|
||||
fun deleteByLabId(labId: UUID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||
import org.springframework.security.config.http.SessionCreationPolicy
|
||||
import org.springframework.security.core.GrantedAuthority
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder
|
||||
@@ -20,7 +19,6 @@ import org.springframework.security.web.SecurityFilterChain
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
|
||||
|
||||
@@ -29,8 +27,11 @@ class SecurityConfig {
|
||||
val converter = JwtAuthenticationConverter()
|
||||
converter.setJwtGrantedAuthoritiesConverter { jwt ->
|
||||
val role = jwt.getClaimAsString("role")
|
||||
if (role.isNullOrBlank()) emptyList()
|
||||
else listOf(SimpleGrantedAuthority("ROLE_$role"))
|
||||
if (role.isNullOrBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(SimpleGrantedAuthority("ROLE_$role"))
|
||||
}
|
||||
}
|
||||
return converter
|
||||
}
|
||||
@@ -44,8 +45,7 @@ class SecurityConfig {
|
||||
.authorizeHttpRequests { auth ->
|
||||
auth.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
|
||||
auth.anyRequest().authenticated()
|
||||
}
|
||||
.oauth2ResourceServer { oauth ->
|
||||
}.oauth2ResourceServer { oauth ->
|
||||
oauth.jwt { jwt ->
|
||||
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
|
||||
}
|
||||
@@ -56,4 +56,4 @@ class SecurityConfig {
|
||||
companion object {
|
||||
private val JWK_URI = System.getenv("LMS_JWT_JWK_SET_URI") ?: "http://localhost:8081/api/v1/auth/jwks.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import org.springframework.mock.web.MockMultipartFile
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.progress.domain.Submission
|
||||
import ru.ysu.lms.progress.repo.SubmissionRepository
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.UUID
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
class SubmissionServiceTest {
|
||||
@TempDir
|
||||
lateinit var tempDir: Path
|
||||
|
||||
private val studentId = UUID.randomUUID()
|
||||
private val labId = UUID.randomUUID()
|
||||
|
||||
private fun service(): Pair<SubmissionService, SubmissionRepository> {
|
||||
val repo = mockk<SubmissionRepository>()
|
||||
val academic = mockk<AcademicClient>()
|
||||
var saved: Submission? = null
|
||||
every { academic.getLabSummary(any(), any()) } returns LabInfo(id = labId.toString(), deadlineAt = null)
|
||||
every { repo.findFirstByStudentIdAndLabIdOrderBySeqDesc(any(), any()) } returns null
|
||||
every { repo.save(any()) } answers {
|
||||
firstArg<Submission>().also { saved = it }
|
||||
}
|
||||
every { repo.findById(any()) } answers { java.util.Optional.ofNullable(saved) }
|
||||
val storage = SubmissionStorage(tempDir.toString())
|
||||
return SubmissionService(repo, storage, academic) to repo
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stores submitted source file and returns it in zip`() {
|
||||
val (service, _) = service()
|
||||
val file = MockMultipartFile("files", "main.c", "text/plain", "#include <stdio.h>\n".toByteArray())
|
||||
|
||||
val dto = service.submit(studentId, labId, listOf(file), "token")
|
||||
|
||||
assertThat(dto.files).containsExactly("main.c")
|
||||
assertThat(dto.seq).isEqualTo(1)
|
||||
|
||||
val (bytes, name) = service.download(studentId, UUID.fromString(dto.id), studentId, isAdmin = false)
|
||||
assertThat(name).endsWith(".zip")
|
||||
val entries = zipEntries(bytes)
|
||||
assertThat(entries.keys).containsExactly("main.c")
|
||||
assertThat(String(entries.getValue("main.c"), Charsets.UTF_8)).contains("#include")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects xml project file renamed to source extension`() {
|
||||
val (service, _) = service()
|
||||
val xml = "<Solution>\n <Configurations/>\n</Solution>".toByteArray()
|
||||
val file = MockMultipartFile("files", "ConsoleApplication1.C", "text/plain", xml)
|
||||
|
||||
assertThatThrownBy { service.submit(studentId, labId, listOf(file), "token") }
|
||||
.isInstanceOf(BadRequestException::class.java)
|
||||
.hasMessageContaining("файл проекта")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `allows zip archive`() {
|
||||
val (service, _) = service()
|
||||
val file = MockMultipartFile("files", "project.zip", "application/zip", byteArrayOf(0x50, 0x4B, 0x03, 0x04))
|
||||
|
||||
val dto = service.submit(studentId, labId, listOf(file), "token")
|
||||
|
||||
assertThat(dto.files).containsExactly("project.zip")
|
||||
}
|
||||
|
||||
private fun zipEntries(bytes: ByteArray): Map<String, ByteArray> {
|
||||
val result = linkedMapOf<String, ByteArray>()
|
||||
ZipInputStream(bytes.inputStream()).use { zin ->
|
||||
var e = zin.nextEntry
|
||||
while (e != null) {
|
||||
if (!e.isDirectory) result[e.name] = zin.readAllBytes()
|
||||
e = zin.nextEntry
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user