From c379be7fba23ac87f40f8384dabbb2fff336b5f4 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Wed, 16 Sep 2026 18:29:44 +0300 Subject: [PATCH] Content: publish/hide chapters, sections and articles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../lms/academic/content/ContentService.kt | 66 ++++++++++++++++--- .../controller/ContentAdminController.kt | 35 ++++++++++ .../controller/ContentPublicController.kt | 3 +- .../ru/ysu/lms/academic/domain/Content.kt | 3 + .../db/migration/V5__content_published.sql | 4 ++ .../lms/web/admin/ContentAdminController.kt | 19 ++++++ .../kotlin/ru/ysu/lms/web/api/ApiClient.kt | 33 ++++++++-- .../main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt | 4 ++ .../ru/ysu/lms/web/learn/LearnController.kt | 23 +++++-- .../resources/templates/learn/article.html | 5 +- .../resources/templates/learn/chapter.html | 7 +- .../main/resources/templates/learn/index.html | 37 ++++++++++- .../resources/templates/learn/section.html | 12 +++- 13 files changed, 225 insertions(+), 26 deletions(-) create mode 100644 services/academic-service/src/main/resources/db/migration/V5__content_published.sql diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt index ebd40b7..344ddf1 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt @@ -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, ) @@ -34,6 +36,7 @@ data class ChapterTreeNode( val title: String, val slug: String, val position: Int, + val published: Boolean = true, val sections: List, ) @@ -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 { 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 diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt index 1163d94..dd6de0c 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt @@ -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("Выберите файл") diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentPublicController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentPublicController.kt index 77ec090..13416b4 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentPublicController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentPublicController.kt @@ -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}") diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Content.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Content.kt index fc31b68..af60660 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Content.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Content.kt @@ -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, ) \ No newline at end of file diff --git a/services/academic-service/src/main/resources/db/migration/V5__content_published.sql b/services/academic-service/src/main/resources/db/migration/V5__content_published.sql new file mode 100644 index 0000000..501163e --- /dev/null +++ b/services/academic-service/src/main/resources/db/migration/V5__content_published.sql @@ -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; diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt index 5345422..7669f59 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt @@ -46,6 +46,25 @@ class ContentAdminController( } } + @PostMapping("/{kind}/toggle-published") + fun togglePublished( + @PathVariable kind: String, + @RequestParam id: String, + @RequestParam published: Boolean, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.setContentPublished(token(session), kind, id, published) + redirect.addFlashAttribute( + "notice", + if (published) "Материал опубликован — теперь его видят студенты" else "Материал скрыт от студентов", + ) + "redirect:/learn" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/learn" + } + @PostMapping("/articles/{id}/save") fun save( @PathVariable id: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt index dd33a7d..5f12759 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt @@ -443,20 +443,45 @@ class ApiClient( } - fun contentTree(token: String?): ContentTreeNodeDto = + fun contentTree(token: String?, all: Boolean = false): ContentTreeNodeDto = call { - val spec = client.get().uri("/api/v1/content/tree") + val uri = if (all) "/api/v1/content/tree/all" else "/api/v1/content/tree" + val spec = client.get().uri(uri) (if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec) .retrieve().body(ContentTreeNodeDto::class.java) } - fun contentArticle(token: String?, chapter: String, section: String, article: String): ContentArticleDto = + fun contentArticle( + token: String?, + chapter: String, + section: String, + article: String, + all: Boolean = false, + ): ContentArticleDto = call { - val spec = client.get().uri("/api/v1/content/{c}/{s}/{a}", chapter, section, article) + val uri = if (all) "/api/v1/content/all/{c}/{s}/{a}" else "/api/v1/content/{c}/{s}/{a}" + val spec = client.get().uri(uri, chapter, section, article) (if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec) .retrieve().body(ContentArticleDto::class.java) } + /** Публикация/скрытие главы, раздела или статьи. kind: chapters|sections|articles */ + fun setContentPublished(token: String, kind: String, id: String, published: Boolean) { + val path = when (kind) { + "chapters" -> "/api/v1/content/chapters/{id}/published" + "sections" -> "/api/v1/content/sections/{id}/published" + "articles" -> "/api/v1/content/articles/{id}/published" + else -> throw IllegalArgumentException("Неизвестный тип материала: $kind") + } + call { + client.put().uri(path, id) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("published" to published)) + .retrieve().toBodilessEntity() + } + } + fun contentTreeList(token: String?): List { val type = object : ParameterizedTypeReference>() {} return call { diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt index 5bb2988..04997bd 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt @@ -305,6 +305,7 @@ data class ContentChapterNodeDto( val title: String = "", val slug: String = "", val position: Int? = 0, + val published: Boolean = true, val sections: List = emptyList(), ) @@ -314,6 +315,7 @@ data class ContentSectionNodeDto( val title: String = "", val slug: String = "", val position: Int? = 0, + val published: Boolean = true, val articles: List = emptyList(), ) @@ -323,6 +325,7 @@ data class ContentArticleNodeDto( val title: String = "", val slug: String = "", val position: Int? = 0, + val published: Boolean = true, ) @JsonIgnoreProperties(ignoreUnknown = true) @@ -335,6 +338,7 @@ data class ContentArticleDto( val slug: String = "", val title: String = "", val bodyMd: String = "", + val published: Boolean = true, ) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt b/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt index 0fddbb9..4000cea 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt @@ -7,9 +7,11 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import ru.ysu.lms.markdown.MarkdownRenderer import ru.ysu.lms.web.api.ApiClient +import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.ContentArticleDto import ru.ysu.lms.web.api.ContentTreeNodeDto import ru.ysu.lms.web.session.SessionKeys +import ru.ysu.lms.web.session.SessionUser @Controller class LearnController( @@ -20,13 +22,16 @@ class LearnController( private fun token(session: HttpSession): String? = session.getAttribute(SessionKeys.TOKEN) as? String + private fun isAdmin(session: HttpSession): Boolean = + (session.getAttribute(SessionKeys.USER) as? SessionUser)?.isAdmin == true + @GetMapping("/learn") fun index(session: HttpSession, model: Model): String { val t = token(session) - val tree = api.contentTree(t) + val admin = isAdmin(session) + val tree = api.contentTree(t, all = admin) model.addAttribute("tree", tree) - val isAdmin = (session.getAttribute(SessionKeys.USER) as? ru.ysu.lms.web.session.SessionUser)?.isAdmin == true - val articles = if (isAdmin && t != null) api.contentTreeList(t) else emptyList() + val articles = if (admin && t != null) api.contentTreeList(t) else emptyList() model.addAttribute("articles", articles) model.addAttribute( "sectionChoices", @@ -49,7 +54,7 @@ class LearnController( session: HttpSession, model: Model, ): String { - val tree: ContentTreeNodeDto = api.contentTree(token(session)) + val tree: ContentTreeNodeDto = api.contentTree(token(session), all = isAdmin(session)) val ch = tree.chapters.firstOrNull { it.slug == chapter } ?: return "redirect:/learn" model.addAttribute("tree", tree) model.addAttribute("chapter", ch) @@ -63,7 +68,7 @@ class LearnController( session: HttpSession, model: Model, ): String { - val tree: ContentTreeNodeDto = api.contentTree(token(session)) + val tree: ContentTreeNodeDto = api.contentTree(token(session), all = isAdmin(session)) val ch = tree.chapters.firstOrNull { it.slug == chapter } ?: return "redirect:/learn" val sec = ch.sections.firstOrNull { it.slug == section } ?: return "redirect:/learn" model.addAttribute("tree", tree) @@ -80,7 +85,11 @@ class LearnController( session: HttpSession, model: Model, ): String { - val a: ContentArticleDto = api.contentArticle(token(session), chapter, section, article) + val a: ContentArticleDto = try { + api.contentArticle(token(session), chapter, section, article, all = isAdmin(session)) + } catch (ex: ApiException) { + return "redirect:/learn" + } model.addAttribute("article", a.toView(markdown.render(a.bodyMd))) return "learn/article" } @@ -95,6 +104,7 @@ data class LearnArticleView( val slug: String, val title: String, val bodyHtml: String, + val published: Boolean, ) fun ContentArticleDto.toView(bodyHtml: String): LearnArticleView = LearnArticleView( @@ -106,4 +116,5 @@ fun ContentArticleDto.toView(bodyHtml: String): LearnArticleView = LearnArticleV slug = slug, title = title, bodyHtml = bodyHtml, + published = published, ) diff --git a/web/src/main/resources/templates/learn/article.html b/web/src/main/resources/templates/learn/article.html index bf8a996..a7bd8f7 100644 --- a/web/src/main/resources/templates/learn/article.html +++ b/web/src/main/resources/templates/learn/article.html @@ -13,7 +13,10 @@ / Статья -

Статья

+

+ Статья + скрыто от студентов +

diff --git a/web/src/main/resources/templates/learn/chapter.html b/web/src/main/resources/templates/learn/chapter.html index 0f8c8e9..a43ce63 100644 --- a/web/src/main/resources/templates/learn/chapter.html +++ b/web/src/main/resources/templates/learn/chapter.html @@ -8,7 +8,10 @@ / Глава -

Глава

+

+ Глава + скрыто +

В этой главе пока нет разделов. @@ -17,11 +20,13 @@

Раздел + скрыто

diff --git a/web/src/main/resources/templates/learn/index.html b/web/src/main/resources/templates/learn/index.html index 1656361..16b21a2 100644 --- a/web/src/main/resources/templates/learn/index.html +++ b/web/src/main/resources/templates/learn/index.html @@ -11,6 +11,10 @@
+
+ Режим администратора: скрытые главы, разделы и статьи помечены значком «скрыто» + и видны только вам. Кнопка-значок публикует материал для студентов или снова скрывает его. +
@@ -25,8 +29,19 @@
-

Глава

-
+

+ Глава + скрыто +

+
+
+ + + + +
Раздел + скрыто
+ + + + + + +
/ Раздел -

Раздел

+

+ Раздел + скрыто +

- +
Статья
— + — + скрыто +
Статей пока нет.