Content: publish/hide chapters, sections and articles
- migration V5 adds published flag (default true) to chapters/sections/articles
- ContentService: tree(includeHidden) filters, treeAll(), findArticle(includeHidden),
set*Published; public tree/article expose only published items
- admin API: GET /tree/all, GET /all/{chapter}/{section}/{article}, PUT
/{chapters|sections|articles}/{id}/published
- web: learn pages for admins load the full tree, show "скрыто" badges and
publish/hide buttons for every chapter, section and article; hidden articles
open in preview for admins, guests get "not found"; article view carries
published flag
This commit is contained in:
@@ -19,6 +19,7 @@ data class ArticleTreeNode(
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val position: Int,
|
||||
val published: Boolean = true,
|
||||
)
|
||||
|
||||
data class SectionTreeNode(
|
||||
@@ -26,6 +27,7 @@ data class SectionTreeNode(
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val position: Int,
|
||||
val published: Boolean = true,
|
||||
val articles: List<ArticleTreeNode>,
|
||||
)
|
||||
|
||||
@@ -34,6 +36,7 @@ data class ChapterTreeNode(
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val position: Int,
|
||||
val published: Boolean = true,
|
||||
val sections: List<SectionTreeNode>,
|
||||
)
|
||||
|
||||
@@ -48,6 +51,7 @@ data class ContentArticleView(
|
||||
val slug: String,
|
||||
val title: String,
|
||||
val bodyMd: String,
|
||||
val published: Boolean = true,
|
||||
)
|
||||
|
||||
data class ContentImportResult(
|
||||
@@ -78,10 +82,14 @@ class ContentService(
|
||||
) {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun tree(): ContentTree {
|
||||
val chapterList = chapters.findAllByOrderByPositionAscIdAsc()
|
||||
val sectionList = sections.findAllByOrderByPositionAscIdAsc().groupBy { it.chapterId }
|
||||
val articleList = articles.findAllByOrderByPositionAscIdAsc().groupBy { it.sectionId }
|
||||
fun tree(includeHidden: Boolean = false): ContentTree {
|
||||
val chapterList = chapters.findAllByOrderByPositionAscIdAsc().filter { includeHidden || it.published }
|
||||
val sectionList = sections.findAllByOrderByPositionAscIdAsc()
|
||||
.filter { includeHidden || it.published }
|
||||
.groupBy { it.chapterId }
|
||||
val articleList = articles.findAllByOrderByPositionAscIdAsc()
|
||||
.filter { includeHidden || it.published }
|
||||
.groupBy { it.sectionId }
|
||||
return ContentTree(
|
||||
chapterList.map { ch ->
|
||||
ChapterTreeNode(
|
||||
@@ -89,14 +97,16 @@ class ContentService(
|
||||
title = ch.title,
|
||||
slug = ch.slug,
|
||||
position = ch.position,
|
||||
published = ch.published,
|
||||
sections = (sectionList[ch.id] ?: emptyList()).map { sec ->
|
||||
SectionTreeNode(
|
||||
id = sec.id.toString(),
|
||||
title = sec.title,
|
||||
slug = sec.slug,
|
||||
position = sec.position,
|
||||
published = sec.published,
|
||||
articles = (articleList[sec.id] ?: emptyList()).map { a ->
|
||||
ArticleTreeNode(a.id.toString(), a.title, a.slug, a.position)
|
||||
ArticleTreeNode(a.id.toString(), a.title, a.slug, a.position, a.published)
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -105,11 +115,23 @@ class ContentService(
|
||||
)
|
||||
}
|
||||
|
||||
/** Полное дерево (включая скрытые) — только для админки. */
|
||||
@Transactional(readOnly = true)
|
||||
fun findArticle(chapterSlug: String, sectionSlug: String, articleSlug: String): ContentArticleView {
|
||||
fun treeAll(): ContentTree = tree(includeHidden = true)
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findArticle(
|
||||
chapterSlug: String,
|
||||
sectionSlug: String,
|
||||
articleSlug: String,
|
||||
includeHidden: Boolean = false,
|
||||
): ContentArticleView {
|
||||
val ch = chapters.findBySlug(chapterSlug) ?: throw NotFoundException("Глава не найдена")
|
||||
if (!includeHidden && !ch.published) throw NotFoundException("Глава скрыта")
|
||||
val sec = sections.findByChapterIdAndSlug(ch.id, sectionSlug) ?: throw NotFoundException("Раздел не найден")
|
||||
if (!includeHidden && !sec.published) throw NotFoundException("Раздел скрыт")
|
||||
val a = articles.findBySectionIdAndSlug(sec.id, articleSlug) ?: throw NotFoundException("Статья не найдена")
|
||||
if (!includeHidden && !a.published) throw NotFoundException("Статья скрыта")
|
||||
return ContentArticleView(
|
||||
id = a.id.toString(),
|
||||
chapterSlug = ch.slug,
|
||||
@@ -119,9 +141,31 @@ class ContentService(
|
||||
slug = a.slug,
|
||||
title = a.title,
|
||||
bodyMd = a.bodyMd,
|
||||
published = a.published,
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun setChapterPublished(chapterId: UUID, published: Boolean) {
|
||||
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
ch.published = published
|
||||
chapters.save(ch)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun setSectionPublished(sectionId: UUID, published: Boolean) {
|
||||
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
sec.published = published
|
||||
sections.save(sec)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun setArticlePublished(articleId: UUID, published: Boolean) {
|
||||
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
|
||||
a.published = published
|
||||
articles.save(a)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun listAll(): List<ContentArticleView> {
|
||||
val chById = chapters.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
|
||||
@@ -164,7 +208,10 @@ class ContentService(
|
||||
?: 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(ch.id.toString(), ch.title, ch.slug, ch.position, emptyList())
|
||||
return ChapterTreeNode(
|
||||
id = ch.id.toString(), title = ch.title, slug = ch.slug,
|
||||
position = ch.position, published = ch.published, sections = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -192,7 +239,10 @@ class ContentService(
|
||||
val sec = sections.save(
|
||||
ContentSection(chapterId = ch.id, title = title.trim(), slug = cleanSlug, position = maxPos + 1),
|
||||
)
|
||||
return SectionTreeNode(sec.id.toString(), sec.title, sec.slug, sec.position, emptyList())
|
||||
return SectionTreeNode(
|
||||
id = sec.id.toString(), title = sec.title, slug = sec.slug,
|
||||
position = sec.position, published = sec.published, articles = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -33,9 +33,44 @@ class ContentAdminController(private val contentService: ContentService) {
|
||||
)
|
||||
data class ArticleMovePayload(val sectionId: String)
|
||||
data class TitlePayload(val title: String)
|
||||
data class PublishedPayload(val published: Boolean)
|
||||
data class ChapterCreatePayload(val title: String, val slug: String? = null)
|
||||
data class SectionCreatePayload(val title: String, val slug: String? = null)
|
||||
|
||||
/** Полное дерево материалов, включая скрытые (для админки). */
|
||||
@GetMapping("/tree/all")
|
||||
fun treeAll() = contentService.treeAll()
|
||||
|
||||
/** Просмотр статьи без учёта публикации — для предпросмотра админом. */
|
||||
@GetMapping("/all/{chapter}/{section}/{article}")
|
||||
fun articleAll(
|
||||
@PathVariable chapter: String,
|
||||
@PathVariable section: String,
|
||||
@PathVariable article: String,
|
||||
): ContentPublicController.ContentArticleViewDto {
|
||||
val a = contentService.findArticle(chapter, section, article, includeHidden = true)
|
||||
return ContentPublicController.ContentArticleViewDto(
|
||||
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle,
|
||||
sectionSlug = a.sectionSlug, sectionTitle = a.sectionTitle,
|
||||
slug = a.slug, title = a.title, bodyMd = a.bodyMd, published = a.published,
|
||||
)
|
||||
}
|
||||
|
||||
@PutMapping("/chapters/{id}/published")
|
||||
fun chapterPublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
|
||||
contentService.setChapterPublished(id, body.published)
|
||||
}
|
||||
|
||||
@PutMapping("/sections/{id}/published")
|
||||
fun sectionPublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
|
||||
contentService.setSectionPublished(id, body.published)
|
||||
}
|
||||
|
||||
@PutMapping("/articles/{id}/published")
|
||||
fun articlePublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
|
||||
contentService.setArticlePublished(id, body.published)
|
||||
}
|
||||
|
||||
@PostMapping("/import")
|
||||
fun import(@RequestParam("file") file: MultipartFile): ContentImportResult {
|
||||
if (file.isEmpty) throw BadRequestException("Выберите файл")
|
||||
|
||||
@@ -32,7 +32,7 @@ class ContentPublicController(
|
||||
return ContentArticleViewDto(
|
||||
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle,
|
||||
sectionSlug = a.sectionSlug, sectionTitle = a.sectionTitle,
|
||||
slug = a.slug, title = a.title, bodyMd = a.bodyMd,
|
||||
slug = a.slug, title = a.title, bodyMd = a.bodyMd, published = a.published,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ class ContentPublicController(
|
||||
val slug: String,
|
||||
val title: String,
|
||||
val bodyMd: String,
|
||||
val published: Boolean = true,
|
||||
)
|
||||
|
||||
@GetMapping("/assets/{name}")
|
||||
|
||||
@@ -14,6 +14,7 @@ class ContentChapter(
|
||||
@Column(nullable = false, length = 200) var title: String = "",
|
||||
@Column(nullable = false, unique = true, length = 200) var slug: String = "",
|
||||
@Column(nullable = false) var position: Int = 0,
|
||||
@Column(nullable = false) var published: Boolean = true,
|
||||
)
|
||||
|
||||
@Entity
|
||||
@@ -24,6 +25,7 @@ class ContentSection(
|
||||
@Column(nullable = false, length = 200) var title: String = "",
|
||||
@Column(nullable = false, length = 200) var slug: String = "",
|
||||
@Column(nullable = false) var position: Int = 0,
|
||||
@Column(nullable = false) var published: Boolean = true,
|
||||
)
|
||||
|
||||
@Entity
|
||||
@@ -35,4 +37,5 @@ class ContentArticle(
|
||||
@Column(nullable = false, length = 200) var slug: String = "",
|
||||
@Column(nullable = false) var bodyMd: String = "",
|
||||
@Column(nullable = false) var position: Int = 0,
|
||||
@Column(nullable = false) var published: Boolean = true,
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Публикация учебных материалов: главы, разделы и статьи можно скрывать от студентов.
|
||||
ALTER TABLE chapters ADD COLUMN published boolean NOT NULL DEFAULT true;
|
||||
ALTER TABLE sections ADD COLUMN published boolean NOT NULL DEFAULT true;
|
||||
ALTER TABLE articles ADD COLUMN published boolean NOT NULL DEFAULT true;
|
||||
Reference in New Issue
Block a user