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:
2026-09-16 18:29:44 +03:00
parent aa1fb5c791
commit 30b8064718
13 changed files with 225 additions and 26 deletions

View File

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

View File

@@ -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("Выберите файл")

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<ContentArticleDto> {
val type = object : ParameterizedTypeReference<List<ContentArticleDto>>() {}
return call {

View File

@@ -305,6 +305,7 @@ data class ContentChapterNodeDto(
val title: String = "",
val slug: String = "",
val position: Int? = 0,
val published: Boolean = true,
val sections: List<ContentSectionNodeDto> = 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<ContentArticleNodeDto> = 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)

View File

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

View File

@@ -13,7 +13,10 @@
<span class="nav-sep">/</span>
<span th:text="${article.title}">Статья</span>
</nav>
<h1 class="page-title" th:text="${article.title}">Статья</h1>
<h1 class="page-title">
<span th:text="${article.title}">Статья</span>
<span th:if="${!article.published}" class="badge badge-warn ml-2">скрыто от студентов</span>
</h1>
<div class="card card-body md-body" th:utext="${article.bodyHtml}"></div>
</div>
</body>

View File

@@ -8,7 +8,10 @@
<span class="nav-sep">/</span>
<span th:text="${chapter.title}">Глава</span>
</nav>
<h1 class="page-title" th:text="${chapter.title}">Глава</h1>
<h1 class="page-title">
<span th:text="${chapter.title}">Глава</span>
<span th:if="${!chapter.published}" class="badge badge-warn ml-2">скрыто</span>
</h1>
<div th:if="${#lists.isEmpty(chapter.sections)}" class="card card-body text-muted">
В этой главе пока нет разделов.
@@ -17,11 +20,13 @@
<div th:each="section : ${chapter.sections}" class="card card-body mb-4">
<h2 class="group-title">
<a th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}" th:text="${section.title}">Раздел</a>
<span th:if="${!section.published}" class="badge badge-warn">скрыто</span>
</h2>
<ul class="list-pad text-sm">
<li th:each="article : ${section.articles}">
<a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
th:text="${article.title}">Статья</a>
<span th:if="${!article.published}" class="badge badge-warn ml-1">скрыто</span>
</li>
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
</ul>

View File

@@ -11,6 +11,10 @@
<button class="btn btn-secondary" type="button" onclick="openModal('modal-new-chapter')">+ Глава</button>
</div>
</div>
<div th:if="${currentUser != null and currentUser.admin}" class="card card-body text-sm text-muted mb-4">
Режим администратора: скрытые главы, разделы и статьи помечены значком «скрыто»
и видны только вам. Кнопка-значок публикует материал для студентов или снова скрывает его.
</div>
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
@@ -25,8 +29,19 @@
</summary>
<div class="card-body acc-body">
<div class="flex flex-wrap items-center justify-between gap-4 mb-4">
<h2 class="section-title" th:text="${chapter.title}">Глава</h2>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2">
<h2 class="section-title m-0">
<span th:text="${chapter.title}">Глава</span>
<span th:if="${!chapter.published}" class="badge badge-warn ml-2">скрыто</span>
</h2>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap items-center gap-2">
<form class="inline-form" th:action="@{/admin/content/chapters/toggle-published}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${chapter.id}">
<input type="hidden" name="published" th:value="${!chapter.published}">
<button class="btn btn-sm" type="submit"
th:classappend="${chapter.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${chapter.published} ? 'Скрыть главу' : 'Опубликовать главу'">Скрыть главу</button>
</form>
<button class="btn btn-secondary btn-sm" type="button"
th:data-modal="|modal-rename-chapter-${chapter.id}|" onclick="openModalData(this)">Переименовать</button>
<form th:action="@{/admin/content/chapters/{id}/delete(id=${chapter.id})}" method="post"
@@ -44,11 +59,20 @@
<h3 class="group-title m-0">
<span class="sub-arrow" aria-hidden="true"></span>
<a th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}" th:text="${section.title}">Раздел</a>
<span th:if="${!section.published}" class="badge badge-warn ml-2">скрыто</span>
</h3>
</summary>
<div class="flex flex-wrap items-center justify-between gap-4 sub-actions">
<div class="spacer"></div>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2">
<form class="inline-form" th:action="@{/admin/content/sections/toggle-published}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${section.id}">
<input type="hidden" name="published" th:value="${!section.published}">
<button class="btn btn-sm" type="submit"
th:classappend="${section.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${section.published} ? 'Скрыть раздел' : 'Опубликовать раздел'">Скрыть раздел</button>
</form>
<button class="btn btn-secondary btn-sm" type="button"
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</button>
<button class="btn btn-secondary btn-sm" type="button"
@@ -64,7 +88,16 @@
<li th:each="article : ${section.articles}" class="flex items-center gap-2">
<a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
th:text="${article.title}">Статья</a>
<span th:if="${!article.published}" class="badge badge-warn">скрыто</span>
<th:block th:if="${currentUser != null and currentUser.admin}">
<form class="inline-form" th:action="@{/admin/content/articles/toggle-published}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${article.id}">
<input type="hidden" name="published" th:value="${!article.published}">
<button class="btn btn-sm" type="submit"
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
</form>
<button class="btn btn-secondary btn-sm" type="button"
th:data-modal="|modal-edit-article-${article.id}|" onclick="openModalData(this)">Править</button>
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post"

View File

@@ -10,14 +10,20 @@
<span class="nav-sep">/</span>
<span th:text="${section.title}">Раздел</span>
</nav>
<h1 class="page-title" th:text="${section.title}">Раздел</h1>
<h1 class="page-title">
<span th:text="${section.title}">Раздел</span>
<span th:if="${!section.published}" class="badge badge-warn ml-2">скрыто</span>
</h1>
<div class="card card-flush">
<table>
<thead><tr><th>Статья</th></tr></thead>
<tbody>
<tr th:each="article : ${section.articles}">
<td><a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
th:text="${article.title}">—</a></td>
<td>
<a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
th:text="${article.title}">—</a>
<span th:if="${!article.published}" class="badge badge-warn ml-1">скрыто</span>
</td>
</tr>
<tr th:if="${#lists.isEmpty(section.articles)}"><td class="empty">Статей пока нет.</td></tr>
</tbody>