Refactor: split ContentService and ProgressService; defaults tightened
This commit is contained in:
@@ -26,8 +26,8 @@ complexity:
|
|||||||
- 'GetMapping'
|
- 'GetMapping'
|
||||||
TooManyFunctions:
|
TooManyFunctions:
|
||||||
# ContentService (26) — следующий кандидат на разделение.
|
# ContentService (26) — следующий кандидат на разделение.
|
||||||
thresholdInFiles: 27
|
thresholdInFiles: 20
|
||||||
thresholdInClasses: 27
|
thresholdInClasses: 20
|
||||||
thresholdInInterfaces: 20
|
thresholdInInterfaces: 20
|
||||||
thresholdInObjects: 20
|
thresholdInObjects: 20
|
||||||
thresholdInEnums: 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.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
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.ContentArticleRepository
|
||||||
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
||||||
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||||
import ru.ysu.lms.common.BadRequestException
|
|
||||||
import ru.ysu.lms.common.NotFoundException
|
import ru.ysu.lms.common.NotFoundException
|
||||||
import java.util.UUID
|
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(
|
private fun <T> requireFound(
|
||||||
value: T?,
|
value: T?,
|
||||||
message: String,
|
message: String,
|
||||||
@@ -257,8 +182,6 @@ class ContentService(
|
|||||||
if (!includeHidden && !published) throw NotFoundException(message)
|
if (!includeHidden && !published) throw NotFoundException(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun uuidOrNull(value: String): UUID? = runCatching { UUID.fromString(value) }.getOrNull()
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun setChapterPublished(
|
fun setChapterPublished(
|
||||||
chapterId: UUID,
|
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
|
@Transactional
|
||||||
fun importZip(
|
fun importZip(
|
||||||
filename: String,
|
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.RequestParam
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import org.springframework.web.multipart.MultipartFile
|
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.ContentImportResult
|
||||||
import ru.ysu.lms.academic.content.ContentService
|
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 ru.ysu.lms.common.BadRequestException
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -25,6 +23,7 @@ import java.util.UUID
|
|||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
class ContentAdminController(
|
class ContentAdminController(
|
||||||
private val contentService: ContentService,
|
private val contentService: ContentService,
|
||||||
|
private val authoring: ContentAuthoringService,
|
||||||
) {
|
) {
|
||||||
data class ArticleUpdatePayload(
|
data class ArticleUpdatePayload(
|
||||||
val title: String,
|
val title: String,
|
||||||
@@ -111,32 +110,6 @@ class ContentAdminController(
|
|||||||
contentService.setArticlePublished(id, body.published)
|
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")
|
@PostMapping("/import")
|
||||||
fun import(
|
fun import(
|
||||||
@RequestParam("file") file: MultipartFile,
|
@RequestParam("file") file: MultipartFile,
|
||||||
@@ -152,19 +125,19 @@ class ContentAdminController(
|
|||||||
@PostMapping("/chapters")
|
@PostMapping("/chapters")
|
||||||
fun createChapter(
|
fun createChapter(
|
||||||
@RequestBody body: ChapterCreatePayload,
|
@RequestBody body: ChapterCreatePayload,
|
||||||
) = contentService.createChapter(body.title, body.slug)
|
) = authoring.createChapter(body.title, body.slug)
|
||||||
|
|
||||||
@PutMapping("/chapters/{id}")
|
@PutMapping("/chapters/{id}")
|
||||||
fun renameChapter(
|
fun renameChapter(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
@RequestBody body: TitlePayload,
|
@RequestBody body: TitlePayload,
|
||||||
) = contentService.renameChapter(id, body.title)
|
) = authoring.renameChapter(id, body.title)
|
||||||
|
|
||||||
@DeleteMapping("/chapters/{id}")
|
@DeleteMapping("/chapters/{id}")
|
||||||
fun deleteChapter(
|
fun deleteChapter(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
): ResponseEntity<Void> {
|
): ResponseEntity<Void> {
|
||||||
contentService.deleteChapter(id)
|
authoring.deleteChapter(id)
|
||||||
return ResponseEntity.noContent().build()
|
return ResponseEntity.noContent().build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,13 +145,13 @@ class ContentAdminController(
|
|||||||
fun createSection(
|
fun createSection(
|
||||||
@RequestParam chapterId: UUID,
|
@RequestParam chapterId: UUID,
|
||||||
@RequestBody body: SectionCreatePayload,
|
@RequestBody body: SectionCreatePayload,
|
||||||
) = contentService.createSection(chapterId, body.title, body.slug)
|
) = authoring.createSection(chapterId, body.title, body.slug)
|
||||||
|
|
||||||
@PutMapping("/sections/{id}")
|
@PutMapping("/sections/{id}")
|
||||||
fun renameSection(
|
fun renameSection(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
@RequestBody body: TitlePayload,
|
@RequestBody body: TitlePayload,
|
||||||
) = contentService.renameSection(id, body.title)
|
) = authoring.renameSection(id, body.title)
|
||||||
|
|
||||||
@DeleteMapping("/sections/{id}")
|
@DeleteMapping("/sections/{id}")
|
||||||
fun deleteSection(
|
fun deleteSection(
|
||||||
@@ -186,9 +159,9 @@ class ContentAdminController(
|
|||||||
@RequestParam(required = false) moveTo: UUID?,
|
@RequestParam(required = false) moveTo: UUID?,
|
||||||
): ResponseEntity<Void> {
|
): ResponseEntity<Void> {
|
||||||
if (moveTo != null) {
|
if (moveTo != null) {
|
||||||
contentService.moveArticlesAndDeleteSection(id, moveTo)
|
authoring.moveArticlesAndDeleteSection(id, moveTo)
|
||||||
} else {
|
} else {
|
||||||
contentService.deleteSection(id)
|
authoring.deleteSection(id)
|
||||||
}
|
}
|
||||||
return ResponseEntity.noContent().build()
|
return ResponseEntity.noContent().build()
|
||||||
}
|
}
|
||||||
@@ -196,7 +169,7 @@ class ContentAdminController(
|
|||||||
@PostMapping("/articles")
|
@PostMapping("/articles")
|
||||||
fun createArticle(
|
fun createArticle(
|
||||||
@RequestBody body: ArticleCreatePayload,
|
@RequestBody body: ArticleCreatePayload,
|
||||||
) = contentService.createArticlePlaced(
|
) = authoring.createArticlePlaced(
|
||||||
title = body.title,
|
title = body.title,
|
||||||
bodyMd = body.bodyMd,
|
bodyMd = body.bodyMd,
|
||||||
chapterId = body.chapterId?.let { UUID.fromString(it) },
|
chapterId = body.chapterId?.let { UUID.fromString(it) },
|
||||||
@@ -210,20 +183,20 @@ class ContentAdminController(
|
|||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
@RequestBody body: ArticleUpdatePayload,
|
@RequestBody body: ArticleUpdatePayload,
|
||||||
) {
|
) {
|
||||||
contentService.updateArticle(id, body.title, body.bodyMd)
|
authoring.updateArticle(id, body.title, body.bodyMd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/articles/{id}/move")
|
@PostMapping("/articles/{id}/move")
|
||||||
fun move(
|
fun move(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
@RequestBody body: ArticleMovePayload,
|
@RequestBody body: ArticleMovePayload,
|
||||||
) = contentService.moveArticle(id, UUID.fromString(body.sectionId))
|
) = authoring.moveArticle(id, UUID.fromString(body.sectionId))
|
||||||
|
|
||||||
@DeleteMapping("/articles/{id}")
|
@DeleteMapping("/articles/{id}")
|
||||||
fun delete(
|
fun delete(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
): ResponseEntity<Void> {
|
): ResponseEntity<Void> {
|
||||||
contentService.deleteArticle(id)
|
authoring.deleteArticle(id)
|
||||||
return ResponseEntity.noContent().build()
|
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
|
package ru.ysu.lms.progress
|
||||||
|
|
||||||
import jakarta.validation.Valid
|
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.access.prepost.PreAuthorize
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
import org.springframework.security.oauth2.jwt.Jwt
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
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.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
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.bind.annotation.RestController
|
||||||
import org.springframework.web.multipart.MultipartFile
|
|
||||||
import java.net.URLEncoder
|
|
||||||
import java.nio.charset.StandardCharsets
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1")
|
@RequestMapping("/api/v1")
|
||||||
class ProgressController(
|
class ProgressController(
|
||||||
private val service: ProgressService,
|
private val service: ProgressService,
|
||||||
|
private val grades: GradeService,
|
||||||
private val publications: GradePublicationService,
|
private val publications: GradePublicationService,
|
||||||
private val submissions: SubmissionService,
|
|
||||||
) {
|
) {
|
||||||
@GetMapping("/me/assignments")
|
@GetMapping("/me/assignments")
|
||||||
fun meAssignments(
|
fun meAssignments(
|
||||||
@@ -86,11 +78,11 @@ class ProgressController(
|
|||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun setGrade(
|
fun setGrade(
|
||||||
@Valid @RequestBody body: GradeSetRequest,
|
@Valid @RequestBody body: GradeSetRequest,
|
||||||
): GradeResponse = service.setGrade(body)
|
): GradeResponse = grades.setGrade(body)
|
||||||
|
|
||||||
@GetMapping("/grades")
|
@GetMapping("/grades")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun listGrades(): List<GradeResponse> = service.listGrades()
|
fun listGrades(): List<GradeResponse> = grades.listGrades()
|
||||||
|
|
||||||
@PostMapping("/grades/publish")
|
@PostMapping("/grades/publish")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
@@ -120,7 +112,7 @@ class ProgressController(
|
|||||||
@AuthenticationPrincipal jwt: Jwt,
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
@RequestParam(required = false) groupId: java.util.UUID?,
|
@RequestParam(required = false) groupId: java.util.UUID?,
|
||||||
): List<GradeResponse> {
|
): 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()
|
if (groupId == null) return emptyList()
|
||||||
return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId)) }
|
return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId)) }
|
||||||
}
|
}
|
||||||
@@ -129,60 +121,5 @@ class ProgressController(
|
|||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun suggestComments(
|
fun suggestComments(
|
||||||
@RequestParam q: String,
|
@RequestParam q: String,
|
||||||
): List<CommentOut> = service.suggestComments(q)
|
): List<CommentOut> = grades.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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,28 +3,17 @@ package ru.ysu.lms.progress
|
|||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import ru.ysu.lms.common.BadRequestException
|
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.StudentLabLevel
|
||||||
import ru.ysu.lms.progress.domain.StudentLabLevelKey
|
import ru.ysu.lms.progress.domain.StudentLabLevelKey
|
||||||
import ru.ysu.lms.progress.domain.VariantAssignment
|
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.StudentLabLevelRepository
|
||||||
import ru.ysu.lms.progress.repo.VariantAssignmentRepository
|
import ru.ysu.lms.progress.repo.VariantAssignmentRepository
|
||||||
import java.math.BigDecimal
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class ProgressService(
|
class ProgressService(
|
||||||
private val levels: StudentLabLevelRepository,
|
private val levels: StudentLabLevelRepository,
|
||||||
private val assignments: VariantAssignmentRepository,
|
private val assignments: VariantAssignmentRepository,
|
||||||
private val grades: LabGradeRepository,
|
|
||||||
private val templates: CommentTemplateRepository,
|
|
||||||
private val gradeComments: GradeCommentRepository,
|
|
||||||
private val academic: AcademicClient,
|
private val academic: AcademicClient,
|
||||||
private val identity: IdentityClient,
|
private val identity: IdentityClient,
|
||||||
) {
|
) {
|
||||||
@@ -270,86 +259,6 @@ class ProgressService(
|
|||||||
@Transactional(readOnly = true)
|
@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 {
|
|
||||||
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)
|
@Transactional(readOnly = true)
|
||||||
fun getStudentAssignment(
|
fun getStudentAssignment(
|
||||||
studentId: UUID,
|
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