Refactor: split ContentService and ProgressService; defaults tightened
This commit is contained in:
@@ -26,8 +26,8 @@ complexity:
|
||||
- 'GetMapping'
|
||||
TooManyFunctions:
|
||||
# ContentService (26) — следующий кандидат на разделение.
|
||||
thresholdInFiles: 27
|
||||
thresholdInClasses: 27
|
||||
thresholdInFiles: 20
|
||||
thresholdInClasses: 20
|
||||
thresholdInInterfaces: 20
|
||||
thresholdInObjects: 20
|
||||
thresholdInEnums: 20
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package ru.ysu.lms.academic.content
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.academic.domain.ContentArticle
|
||||
import ru.ysu.lms.academic.domain.ContentChapter
|
||||
import ru.ysu.lms.academic.domain.ContentSection
|
||||
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
||||
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
||||
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.common.NotFoundException
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class ContentAuthoringService(
|
||||
private val chapters: ContentChapterRepository,
|
||||
private val sections: ContentSectionRepository,
|
||||
private val articles: ContentArticleRepository,
|
||||
) {
|
||||
@Transactional
|
||||
fun updateArticle(
|
||||
articleId: UUID,
|
||||
title: String,
|
||||
bodyMd: String,
|
||||
) {
|
||||
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
|
||||
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
|
||||
a.title = title.trim()
|
||||
a.bodyMd = bodyMd
|
||||
articles.save(a)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteArticle(articleId: UUID) {
|
||||
if (!articles.existsById(articleId)) throw NotFoundException("Статья не найдена")
|
||||
articles.deleteById(articleId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun createChapter(
|
||||
title: String,
|
||||
slug: String?,
|
||||
): ChapterTreeNode {
|
||||
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
|
||||
val cleanSlug =
|
||||
slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
|
||||
?: Slugs.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(),
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun renameChapter(
|
||||
chapterId: UUID,
|
||||
title: String,
|
||||
) {
|
||||
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
|
||||
ch.title = title.trim()
|
||||
chapters.save(ch)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteChapter(chapterId: UUID) {
|
||||
if (!chapters.existsById(chapterId)) throw NotFoundException("Глава не найдена")
|
||||
chapters.deleteById(chapterId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun createSection(
|
||||
chapterId: UUID,
|
||||
title: String,
|
||||
slug: String?,
|
||||
): SectionTreeNode {
|
||||
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым")
|
||||
val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id)
|
||||
val cleanSlug =
|
||||
slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
|
||||
?: Slugs.uniqueSlug(title, siblings.map { it.slug }.toSet())
|
||||
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
|
||||
val sec =
|
||||
sections.save(
|
||||
ContentSection(chapterId = ch.id, title = title.trim(), slug = cleanSlug, position = maxPos + 1),
|
||||
)
|
||||
return SectionTreeNode(
|
||||
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,
|
||||
) {
|
||||
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
|
||||
sec.title = title.trim()
|
||||
sections.save(sec)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteSection(sectionId: UUID) {
|
||||
if (!sections.existsById(sectionId)) throw NotFoundException("Раздел не найден")
|
||||
sections.deleteById(sectionId)
|
||||
}
|
||||
|
||||
/** Переносит все статьи раздела в другой и удаляет раздел. */
|
||||
@Transactional
|
||||
fun moveArticlesAndDeleteSection(
|
||||
sectionId: UUID,
|
||||
targetSectionId: UUID,
|
||||
) {
|
||||
val src = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
val dst = sections.findById(targetSectionId).orElseThrow { NotFoundException("Раздел назначения не найден") }
|
||||
if (src.id == dst.id) throw BadRequestException("Раздел назначения совпадает с исходным")
|
||||
val moving = articles.findBySectionIdOrderByPositionAscIdAsc(src.id)
|
||||
var pos = articles.findBySectionIdOrderByPositionAscIdAsc(dst.id).maxOfOrNull { it.position } ?: 0
|
||||
for (a in moving) {
|
||||
pos += 1
|
||||
a.sectionId = dst.id
|
||||
a.position = pos
|
||||
articles.save(a)
|
||||
}
|
||||
sections.delete(src)
|
||||
}
|
||||
|
||||
/** Создаёт статью; если раздела нет — создаёт главу/раздел по названиям. */
|
||||
@Transactional
|
||||
fun createArticlePlaced(
|
||||
title: String,
|
||||
bodyMd: String,
|
||||
chapterId: UUID?,
|
||||
newChapterTitle: String?,
|
||||
sectionId: UUID?,
|
||||
newSectionTitle: String?,
|
||||
): ContentArticleView {
|
||||
val targetSection: ContentSection =
|
||||
when {
|
||||
sectionId != null && !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("Укажите главу")
|
||||
}
|
||||
val node = createSection(chapter.id, newSectionTitle, null)
|
||||
sections
|
||||
.findById(UUID.fromString(node.id))
|
||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||
}
|
||||
else -> throw BadRequestException("Укажите раздел")
|
||||
}
|
||||
return createArticle(targetSection.id, title, bodyMd)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun createArticle(
|
||||
sectionId: UUID,
|
||||
title: String,
|
||||
bodyMd: String,
|
||||
): ContentArticleView {
|
||||
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
|
||||
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
|
||||
val slug = Slugs.uniqueSlug(title, siblings.map { it.slug }.toSet())
|
||||
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
|
||||
val a =
|
||||
articles.save(
|
||||
ContentArticle(sectionId = sec.id, title = title.trim(), slug = slug, bodyMd = bodyMd, position = maxPos + 1),
|
||||
)
|
||||
return ContentArticleView(
|
||||
id = a.id.toString(),
|
||||
chapterSlug = ch.slug,
|
||||
chapterTitle = ch.title,
|
||||
sectionSlug = sec.slug,
|
||||
sectionTitle = sec.title,
|
||||
slug = a.slug,
|
||||
title = a.title,
|
||||
bodyMd = a.bodyMd,
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun moveArticle(
|
||||
articleId: UUID,
|
||||
newSectionId: UUID,
|
||||
): ContentArticleView {
|
||||
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
|
||||
val sec = sections.findById(newSectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
|
||||
a.sectionId = sec.id
|
||||
a.position = (siblings.maxOfOrNull { it.position } ?: 0) + 1
|
||||
articles.save(a)
|
||||
return ContentArticleView(
|
||||
id = a.id.toString(),
|
||||
chapterSlug = ch.slug,
|
||||
chapterTitle = ch.title,
|
||||
sectionSlug = sec.slug,
|
||||
sectionTitle = sec.title,
|
||||
slug = a.slug,
|
||||
title = a.title,
|
||||
bodyMd = a.bodyMd,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package ru.ysu.lms.academic.content
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
||||
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
||||
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class ContentOrderService(
|
||||
private val chapters: ContentChapterRepository,
|
||||
private val sections: ContentSectionRepository,
|
||||
private val articles: ContentArticleRepository,
|
||||
) {
|
||||
/** Применяет новое расположение глав, разделов и статей (drag & drop в админке). */
|
||||
@Transactional
|
||||
fun reorder(tree: ReorderTree) {
|
||||
tree.chapters.forEachIndexed { chapterIndex, chapterDto ->
|
||||
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
|
||||
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
|
||||
article.sectionId = section.id
|
||||
article.position = articleIndex + 1
|
||||
articles.save(article)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Порядок разделов внутри главы и статей внутри них (drag & drop на странице главы). */
|
||||
@Transactional
|
||||
fun reorderSections(
|
||||
chapterId: UUID,
|
||||
tree: List<ReorderSection>,
|
||||
) {
|
||||
tree.forEachIndexed { sectionIndex, sectionDto ->
|
||||
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
|
||||
article.sectionId = section.id
|
||||
article.position = articleIndex + 1
|
||||
articles.save(article)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Порядок статей внутри одного раздела (drag & drop на странице раздела). */
|
||||
@Transactional
|
||||
fun reorderArticles(
|
||||
sectionId: UUID,
|
||||
tree: List<ReorderArticle>,
|
||||
) {
|
||||
tree.forEachIndexed { articleIndex, articleDto ->
|
||||
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()
|
||||
}
|
||||
@@ -2,13 +2,9 @@ package ru.ysu.lms.academic.content
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.academic.domain.ContentArticle
|
||||
import ru.ysu.lms.academic.domain.ContentChapter
|
||||
import ru.ysu.lms.academic.domain.ContentSection
|
||||
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
||||
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
||||
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.common.NotFoundException
|
||||
import java.util.UUID
|
||||
|
||||
@@ -173,77 +169,6 @@ class ContentService(
|
||||
)
|
||||
}
|
||||
|
||||
/** Применяет новое расположение глав, разделов и статей (drag & drop в админке). */
|
||||
@Transactional
|
||||
fun reorder(tree: ReorderTree) {
|
||||
tree.chapters.forEachIndexed { chapterIndex, chapterDto ->
|
||||
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
|
||||
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
|
||||
article.sectionId = section.id
|
||||
article.position = articleIndex + 1
|
||||
articles.save(article)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Порядок разделов внутри главы и статей внутри них (drag & drop на странице главы). */
|
||||
@Transactional
|
||||
fun reorderSections(
|
||||
chapterId: UUID,
|
||||
tree: List<ReorderSection>,
|
||||
) {
|
||||
tree.forEachIndexed { sectionIndex, sectionDto ->
|
||||
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
|
||||
article.sectionId = section.id
|
||||
article.position = articleIndex + 1
|
||||
articles.save(article)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Порядок статей внутри одного раздела (drag & drop на странице раздела). */
|
||||
@Transactional
|
||||
fun reorderArticles(
|
||||
sectionId: UUID,
|
||||
tree: List<ReorderArticle>,
|
||||
) {
|
||||
tree.forEachIndexed { articleIndex, articleDto ->
|
||||
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 <T> requireFound(
|
||||
value: T?,
|
||||
message: String,
|
||||
@@ -257,8 +182,6 @@ class ContentService(
|
||||
if (!includeHidden && !published) throw NotFoundException(message)
|
||||
}
|
||||
|
||||
private fun uuidOrNull(value: String): UUID? = runCatching { UUID.fromString(value) }.getOrNull()
|
||||
|
||||
@Transactional
|
||||
fun setChapterPublished(
|
||||
chapterId: UUID,
|
||||
@@ -309,230 +232,6 @@ class ContentService(
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateArticle(
|
||||
articleId: UUID,
|
||||
title: String,
|
||||
bodyMd: String,
|
||||
) {
|
||||
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
|
||||
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
|
||||
a.title = title.trim()
|
||||
a.bodyMd = bodyMd
|
||||
articles.save(a)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteArticle(articleId: UUID) {
|
||||
if (!articles.existsById(articleId)) throw NotFoundException("Статья не найдена")
|
||||
articles.deleteById(articleId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun createChapter(
|
||||
title: String,
|
||||
slug: String?,
|
||||
): ChapterTreeNode {
|
||||
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
|
||||
val cleanSlug =
|
||||
slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
|
||||
?: Slugs.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(),
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun renameChapter(
|
||||
chapterId: UUID,
|
||||
title: String,
|
||||
) {
|
||||
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
|
||||
ch.title = title.trim()
|
||||
chapters.save(ch)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteChapter(chapterId: UUID) {
|
||||
if (!chapters.existsById(chapterId)) throw NotFoundException("Глава не найдена")
|
||||
chapters.deleteById(chapterId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun createSection(
|
||||
chapterId: UUID,
|
||||
title: String,
|
||||
slug: String?,
|
||||
): SectionTreeNode {
|
||||
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым")
|
||||
val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id)
|
||||
val cleanSlug =
|
||||
slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
|
||||
?: Slugs.uniqueSlug(title, siblings.map { it.slug }.toSet())
|
||||
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
|
||||
val sec =
|
||||
sections.save(
|
||||
ContentSection(chapterId = ch.id, title = title.trim(), slug = cleanSlug, position = maxPos + 1),
|
||||
)
|
||||
return SectionTreeNode(
|
||||
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,
|
||||
) {
|
||||
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
|
||||
sec.title = title.trim()
|
||||
sections.save(sec)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteSection(sectionId: UUID) {
|
||||
if (!sections.existsById(sectionId)) throw NotFoundException("Раздел не найден")
|
||||
sections.deleteById(sectionId)
|
||||
}
|
||||
|
||||
/** Переносит все статьи раздела в другой и удаляет раздел. */
|
||||
@Transactional
|
||||
fun moveArticlesAndDeleteSection(
|
||||
sectionId: UUID,
|
||||
targetSectionId: UUID,
|
||||
) {
|
||||
val src = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
val dst = sections.findById(targetSectionId).orElseThrow { NotFoundException("Раздел назначения не найден") }
|
||||
if (src.id == dst.id) throw BadRequestException("Раздел назначения совпадает с исходным")
|
||||
val moving = articles.findBySectionIdOrderByPositionAscIdAsc(src.id)
|
||||
var pos = articles.findBySectionIdOrderByPositionAscIdAsc(dst.id).maxOfOrNull { it.position } ?: 0
|
||||
for (a in moving) {
|
||||
pos += 1
|
||||
a.sectionId = dst.id
|
||||
a.position = pos
|
||||
articles.save(a)
|
||||
}
|
||||
sections.delete(src)
|
||||
}
|
||||
|
||||
/** Создаёт статью; если раздела нет — создаёт главу/раздел по названиям. */
|
||||
@Transactional
|
||||
fun createArticlePlaced(
|
||||
title: String,
|
||||
bodyMd: String,
|
||||
chapterId: UUID?,
|
||||
newChapterTitle: String?,
|
||||
sectionId: UUID?,
|
||||
newSectionTitle: String?,
|
||||
): ContentArticleView {
|
||||
val targetSection: ContentSection =
|
||||
when {
|
||||
sectionId != null && !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("Укажите главу")
|
||||
}
|
||||
val node = createSection(chapter.id, newSectionTitle, null)
|
||||
sections
|
||||
.findById(UUID.fromString(node.id))
|
||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||
}
|
||||
else -> throw BadRequestException("Укажите раздел")
|
||||
}
|
||||
return createArticle(targetSection.id, title, bodyMd)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun createArticle(
|
||||
sectionId: UUID,
|
||||
title: String,
|
||||
bodyMd: String,
|
||||
): ContentArticleView {
|
||||
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
|
||||
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
|
||||
val slug = Slugs.uniqueSlug(title, siblings.map { it.slug }.toSet())
|
||||
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
|
||||
val a =
|
||||
articles.save(
|
||||
ContentArticle(sectionId = sec.id, title = title.trim(), slug = slug, bodyMd = bodyMd, position = maxPos + 1),
|
||||
)
|
||||
return ContentArticleView(
|
||||
id = a.id.toString(),
|
||||
chapterSlug = ch.slug,
|
||||
chapterTitle = ch.title,
|
||||
sectionSlug = sec.slug,
|
||||
sectionTitle = sec.title,
|
||||
slug = a.slug,
|
||||
title = a.title,
|
||||
bodyMd = a.bodyMd,
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun moveArticle(
|
||||
articleId: UUID,
|
||||
newSectionId: UUID,
|
||||
): ContentArticleView {
|
||||
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
|
||||
val sec = sections.findById(newSectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
|
||||
a.sectionId = sec.id
|
||||
a.position = (siblings.maxOfOrNull { it.position } ?: 0) + 1
|
||||
articles.save(a)
|
||||
return ContentArticleView(
|
||||
id = a.id.toString(),
|
||||
chapterSlug = ch.slug,
|
||||
chapterTitle = ch.title,
|
||||
sectionSlug = sec.slug,
|
||||
sectionTitle = sec.title,
|
||||
slug = a.slug,
|
||||
title = a.title,
|
||||
bodyMd = a.bodyMd,
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun importZip(
|
||||
filename: String,
|
||||
|
||||
@@ -12,11 +12,9 @@ import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import ru.ysu.lms.academic.content.ContentAuthoringService
|
||||
import ru.ysu.lms.academic.content.ContentImportResult
|
||||
import ru.ysu.lms.academic.content.ContentService
|
||||
import ru.ysu.lms.academic.content.ReorderArticles
|
||||
import ru.ysu.lms.academic.content.ReorderSections
|
||||
import ru.ysu.lms.academic.content.ReorderTree
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import java.util.UUID
|
||||
|
||||
@@ -25,6 +23,7 @@ import java.util.UUID
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
class ContentAdminController(
|
||||
private val contentService: ContentService,
|
||||
private val authoring: ContentAuthoringService,
|
||||
) {
|
||||
data class ArticleUpdatePayload(
|
||||
val title: String,
|
||||
@@ -111,32 +110,6 @@ class ContentAdminController(
|
||||
contentService.setArticlePublished(id, body.published)
|
||||
}
|
||||
|
||||
/** Новое расположение материалов (drag & drop глав, разделов и статей). */
|
||||
@PostMapping("/reorder")
|
||||
fun reorder(
|
||||
@RequestBody body: ReorderTree,
|
||||
) {
|
||||
contentService.reorder(body)
|
||||
}
|
||||
|
||||
/** Порядок разделов внутри главы (drag & drop на странице главы). */
|
||||
@PostMapping("/chapters/{id}/sections/reorder")
|
||||
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,
|
||||
) {
|
||||
contentService.reorderArticles(id, body.articles)
|
||||
}
|
||||
|
||||
@PostMapping("/import")
|
||||
fun import(
|
||||
@RequestParam("file") file: MultipartFile,
|
||||
@@ -152,19 +125,19 @@ class ContentAdminController(
|
||||
@PostMapping("/chapters")
|
||||
fun createChapter(
|
||||
@RequestBody body: ChapterCreatePayload,
|
||||
) = contentService.createChapter(body.title, body.slug)
|
||||
) = authoring.createChapter(body.title, body.slug)
|
||||
|
||||
@PutMapping("/chapters/{id}")
|
||||
fun renameChapter(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: TitlePayload,
|
||||
) = contentService.renameChapter(id, body.title)
|
||||
) = authoring.renameChapter(id, body.title)
|
||||
|
||||
@DeleteMapping("/chapters/{id}")
|
||||
fun deleteChapter(
|
||||
@PathVariable id: UUID,
|
||||
): ResponseEntity<Void> {
|
||||
contentService.deleteChapter(id)
|
||||
authoring.deleteChapter(id)
|
||||
return ResponseEntity.noContent().build()
|
||||
}
|
||||
|
||||
@@ -172,13 +145,13 @@ class ContentAdminController(
|
||||
fun createSection(
|
||||
@RequestParam chapterId: UUID,
|
||||
@RequestBody body: SectionCreatePayload,
|
||||
) = contentService.createSection(chapterId, body.title, body.slug)
|
||||
) = authoring.createSection(chapterId, body.title, body.slug)
|
||||
|
||||
@PutMapping("/sections/{id}")
|
||||
fun renameSection(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: TitlePayload,
|
||||
) = contentService.renameSection(id, body.title)
|
||||
) = authoring.renameSection(id, body.title)
|
||||
|
||||
@DeleteMapping("/sections/{id}")
|
||||
fun deleteSection(
|
||||
@@ -186,9 +159,9 @@ class ContentAdminController(
|
||||
@RequestParam(required = false) moveTo: UUID?,
|
||||
): ResponseEntity<Void> {
|
||||
if (moveTo != null) {
|
||||
contentService.moveArticlesAndDeleteSection(id, moveTo)
|
||||
authoring.moveArticlesAndDeleteSection(id, moveTo)
|
||||
} else {
|
||||
contentService.deleteSection(id)
|
||||
authoring.deleteSection(id)
|
||||
}
|
||||
return ResponseEntity.noContent().build()
|
||||
}
|
||||
@@ -196,7 +169,7 @@ class ContentAdminController(
|
||||
@PostMapping("/articles")
|
||||
fun createArticle(
|
||||
@RequestBody body: ArticleCreatePayload,
|
||||
) = contentService.createArticlePlaced(
|
||||
) = authoring.createArticlePlaced(
|
||||
title = body.title,
|
||||
bodyMd = body.bodyMd,
|
||||
chapterId = body.chapterId?.let { UUID.fromString(it) },
|
||||
@@ -210,20 +183,20 @@ class ContentAdminController(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: ArticleUpdatePayload,
|
||||
) {
|
||||
contentService.updateArticle(id, body.title, body.bodyMd)
|
||||
authoring.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))
|
||||
) = authoring.moveArticle(id, UUID.fromString(body.sectionId))
|
||||
|
||||
@DeleteMapping("/articles/{id}")
|
||||
fun delete(
|
||||
@PathVariable id: UUID,
|
||||
): ResponseEntity<Void> {
|
||||
contentService.deleteArticle(id)
|
||||
authoring.deleteArticle(id)
|
||||
return ResponseEntity.noContent().build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package ru.ysu.lms.academic.controller
|
||||
|
||||
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 ru.ysu.lms.academic.content.ContentOrderService
|
||||
import ru.ysu.lms.academic.content.ReorderArticles
|
||||
import ru.ysu.lms.academic.content.ReorderSections
|
||||
import ru.ysu.lms.academic.content.ReorderTree
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
class ContentOrderController(
|
||||
private val order: ContentOrderService,
|
||||
) {
|
||||
/** Новое расположение материалов (drag & drop глав, разделов и статей). */
|
||||
@PostMapping("/reorder")
|
||||
fun reorder(
|
||||
@RequestBody body: ReorderTree,
|
||||
) {
|
||||
order.reorder(body)
|
||||
}
|
||||
|
||||
/** Порядок разделов внутри главы (drag & drop на странице главы). */
|
||||
@PostMapping("/chapters/{id}/sections/reorder")
|
||||
fun reorderSections(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: ReorderSections,
|
||||
) {
|
||||
order.reorderSections(id, body.sections)
|
||||
}
|
||||
|
||||
/** Порядок статей внутри раздела (drag & drop на странице раздела). */
|
||||
@PostMapping("/sections/{id}/articles/reorder")
|
||||
fun reorderArticles(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody body: ReorderArticles,
|
||||
) {
|
||||
order.reorderArticles(id, body.articles)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.progress.domain.CommentTemplate
|
||||
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.repo.CommentTemplateRepository
|
||||
import ru.ysu.lms.progress.repo.GradeCommentRepository
|
||||
import ru.ysu.lms.progress.repo.LabGradeRepository
|
||||
import java.math.BigDecimal
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class GradeService(
|
||||
private val grades: LabGradeRepository,
|
||||
private val templates: CommentTemplateRepository,
|
||||
private val gradeComments: GradeCommentRepository,
|
||||
) {
|
||||
@Transactional
|
||||
fun setGrade(request: GradeSetRequest): GradeResponse {
|
||||
if (request.score < BigDecimal.ZERO || request.score > BigDecimal("5")) {
|
||||
throw BadRequestException("Оценка должна быть в диапазоне 0–5")
|
||||
}
|
||||
val sid = UUID.fromString(request.studentId)
|
||||
val lid = UUID.fromString(request.labId)
|
||||
val key = LabGradeKey(sid, lid)
|
||||
val grade = grades.findById(key).orElseGet { LabGrade(studentId = key.studentId, labId = key.labId) }
|
||||
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) },
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
gradeComments.deleteByStudentIdAndLabId(sid, lid)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun suggestComments(query: String): List<CommentOut> =
|
||||
templates
|
||||
.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim())
|
||||
.map { CommentOut(it.text, it.tone) }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun myComments(studentId: UUID): List<GradeComment> = gradeComments.findByStudentId(studentId)
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun listGrades(): List<GradeResponse> {
|
||||
val byGrade =
|
||||
gradeComments
|
||||
.findAll()
|
||||
.groupBy { it.studentId to it.labId }
|
||||
return grades.findAllBy().map {
|
||||
GradeResponse(
|
||||
studentId = it.studentId.toString(),
|
||||
labId = it.labId.toString(),
|
||||
score = it.score,
|
||||
comments = (byGrade[it.studentId to it.labId] ?: emptyList()).map { c -> CommentOut(c.text, c.tone) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,23 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
import jakarta.validation.Valid
|
||||
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.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
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
class ProgressController(
|
||||
private val service: ProgressService,
|
||||
private val grades: GradeService,
|
||||
private val publications: GradePublicationService,
|
||||
private val submissions: SubmissionService,
|
||||
) {
|
||||
@GetMapping("/me/assignments")
|
||||
fun meAssignments(
|
||||
@@ -86,11 +78,11 @@ class ProgressController(
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun setGrade(
|
||||
@Valid @RequestBody body: GradeSetRequest,
|
||||
): GradeResponse = service.setGrade(body)
|
||||
): GradeResponse = grades.setGrade(body)
|
||||
|
||||
@GetMapping("/grades")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun listGrades(): List<GradeResponse> = service.listGrades()
|
||||
fun listGrades(): List<GradeResponse> = grades.listGrades()
|
||||
|
||||
@PostMapping("/grades/publish")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@@ -120,7 +112,7 @@ class ProgressController(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
@RequestParam(required = false) groupId: java.util.UUID?,
|
||||
): List<GradeResponse> {
|
||||
val all = service.listGrades().filter { it.studentId == jwt.subject }
|
||||
val all = grades.listGrades().filter { it.studentId == jwt.subject }
|
||||
if (groupId == null) return emptyList()
|
||||
return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId)) }
|
||||
}
|
||||
@@ -129,60 +121,5 @@ class ProgressController(
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun suggestComments(
|
||||
@RequestParam q: String,
|
||||
): List<CommentOut> = service.suggestComments(q)
|
||||
|
||||
@PostMapping("/me/submissions/{labId}")
|
||||
fun submit(
|
||||
@PathVariable labId: UUID,
|
||||
@RequestPart("files") files: List<MultipartFile>,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<SubmissionDto> {
|
||||
val sid = UUID.fromString(jwt.subject)
|
||||
val all = submissions.submit(sid, labId, files, jwt.tokenValue)
|
||||
return submissions.mySubmissions(sid, labId)
|
||||
}
|
||||
|
||||
@GetMapping("/me/submissions")
|
||||
fun mySubmissions(
|
||||
@RequestParam labId: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<SubmissionDto> = submissions.mySubmissions(UUID.fromString(jwt.subject), labId)
|
||||
|
||||
@GetMapping("/me/submissions/{id}/download")
|
||||
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)
|
||||
}
|
||||
|
||||
@GetMapping("/submissions")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
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> {
|
||||
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()
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename*=UTF-8''${URLEncoder.encode(name, StandardCharsets.UTF_8)}",
|
||||
).contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.body(bytes)
|
||||
): List<CommentOut> = grades.suggestComments(q)
|
||||
}
|
||||
|
||||
@@ -3,28 +3,17 @@ package ru.ysu.lms.progress
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.progress.domain.CommentTemplate
|
||||
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 java.math.BigDecimal
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class ProgressService(
|
||||
private val levels: StudentLabLevelRepository,
|
||||
private val assignments: VariantAssignmentRepository,
|
||||
private val grades: LabGradeRepository,
|
||||
private val templates: CommentTemplateRepository,
|
||||
private val gradeComments: GradeCommentRepository,
|
||||
private val academic: AcademicClient,
|
||||
private val identity: IdentityClient,
|
||||
) {
|
||||
@@ -270,86 +259,6 @@ class ProgressService(
|
||||
@Transactional(readOnly = true)
|
||||
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> = assignments.findByStudentId(studentId)
|
||||
|
||||
@Transactional
|
||||
fun setGrade(request: GradeSetRequest): GradeResponse {
|
||||
if (request.score < BigDecimal.ZERO || request.score > BigDecimal("5")) {
|
||||
throw BadRequestException("Оценка должна быть в диапазоне 0–5")
|
||||
}
|
||||
val sid = UUID.fromString(request.studentId)
|
||||
val lid = UUID.fromString(request.labId)
|
||||
val key = LabGradeKey(sid, lid)
|
||||
val grade = grades.findById(key).orElseGet { LabGrade(studentId = key.studentId, labId = key.labId) }
|
||||
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) },
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
gradeComments.deleteByStudentIdAndLabId(sid, lid)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun suggestComments(query: String): List<CommentOut> =
|
||||
templates
|
||||
.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim())
|
||||
.map { CommentOut(it.text, it.tone) }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun myComments(studentId: UUID): List<GradeComment> = gradeComments.findByStudentId(studentId)
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun listGrades(): List<GradeResponse> {
|
||||
val byGrade =
|
||||
gradeComments
|
||||
.findAll()
|
||||
.groupBy { it.studentId to it.labId }
|
||||
return grades.findAllBy().map {
|
||||
GradeResponse(
|
||||
studentId = it.studentId.toString(),
|
||||
labId = it.labId.toString(),
|
||||
score = it.score,
|
||||
comments = (byGrade[it.studentId to it.labId] ?: emptyList()).map { c -> CommentOut(c.text, c.tone) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getStudentAssignment(
|
||||
studentId: UUID,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
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.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.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
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
class SubmissionController(
|
||||
private val submissions: SubmissionService,
|
||||
) {
|
||||
@PostMapping("/me/submissions/{labId}")
|
||||
fun submit(
|
||||
@PathVariable labId: UUID,
|
||||
@RequestPart("files") files: List<MultipartFile>,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<SubmissionDto> {
|
||||
val sid = UUID.fromString(jwt.subject)
|
||||
submissions.submit(sid, labId, files, jwt.tokenValue)
|
||||
return submissions.mySubmissions(sid, labId)
|
||||
}
|
||||
|
||||
@GetMapping("/me/submissions")
|
||||
fun mySubmissions(
|
||||
@RequestParam labId: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<SubmissionDto> = submissions.mySubmissions(UUID.fromString(jwt.subject), labId)
|
||||
|
||||
@GetMapping("/me/submissions/{id}/download")
|
||||
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)
|
||||
}
|
||||
|
||||
@GetMapping("/submissions")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
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> {
|
||||
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()
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename*=UTF-8''${URLEncoder.encode(name, StandardCharsets.UTF_8)}",
|
||||
).contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.body(bytes)
|
||||
}
|
||||
Reference in New Issue
Block a user