Refactor: split ContentService and ProgressService; defaults tightened

This commit is contained in:
2026-09-27 22:48:13 +03:00
parent 290b2a5a20
commit 4035bc5a0f
10 changed files with 577 additions and 502 deletions

View File

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

View File

@@ -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()
}

View File

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

View File

@@ -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()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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