feat(web): страницы материалов курса

This commit is contained in:
2026-10-27 19:01:30 +03:00
parent 43229059d3
commit 0b5d5d0ca5
12 changed files with 448 additions and 1 deletions

View File

@@ -43,6 +43,36 @@ fun ApiClient.contentTreeList(token: String?): List<ContentArticleDto> {
}
}
/** Дерево материалов курса: доступно назначенным преподавателям, студентам групп курса и админу. */
fun ApiClient.courseContentTree(
token: String,
courseId: String,
): ContentTreeNodeDto =
call {
client
.get()
.uri("/api/v1/courses/{courseId}/content/tree", courseId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(ContentTreeNodeDto::class.java)
}
fun ApiClient.courseContentArticle(
token: String,
courseId: String,
chapter: String,
section: String,
article: String,
): ContentArticleDto =
call {
client
.get()
.uri("/api/v1/courses/{courseId}/content/{c}/{s}/{a}", courseId, chapter, section, article)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(ContentArticleDto::class.java)
}
fun ApiClient.importContent(
token: String,
filename: String,

View File

@@ -57,6 +57,20 @@ fun ApiClient.courseDetail(
.body(CourseDetailDto::class.java)
}
/** Курс по человекочитаемому номеру из URL (`course_no`). */
fun ApiClient.courseByNo(
token: String,
courseNo: Long,
): CourseDetailDto =
call {
client
.get()
.uri("/api/v1/courses/by-no/{courseNo}", courseNo)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(CourseDetailDto::class.java)
}
/** Состав группы в рамках курса: доступен преподавателю курса и администратору. */
fun ApiClient.courseGroupStudents(
token: String,

View File

@@ -57,7 +57,7 @@ class WebConfig(
)
registry
.addInterceptor(authInterceptor)
.addPathPatterns("/admin/**", "/student/**", "/teacher/**", "/change-password")
.addPathPatterns("/admin/**", "/student/**", "/teacher/**", "/change-password", "/learn/course/**")
registry
.addInterceptor(adminInterceptor)
.addPathPatterns("/admin/**")

View File

@@ -0,0 +1,116 @@
package ru.ysu.lms.web.learn
import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
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.api.courseByNo
import ru.ysu.lms.web.api.courseContentArticle
import ru.ysu.lms.web.api.courseContentTree
import ru.ysu.lms.web.session.SessionKeys
/** Ссылка на статью курса для навигации «назад/далее» и бокового содержания. */
data class CourseLearnArticleLink(
val chapterSlug: String = "",
val chapterTitle: String = "",
val sectionSlug: String = "",
val sectionTitle: String = "",
val slug: String = "",
val title: String = "",
)
/**
* Материалы курса для авторизованных пользователей. Доступ к самому курсу
* проверяет academic-service по токену из сессии; анонимного пользователя
* разворачивает AuthInterceptor.
*/
@Controller
class CourseLearnController(
private val api: ApiClient,
private val markdown: MarkdownRenderer,
) {
private fun token(session: HttpSession): String? = session.getAttribute(SessionKeys.TOKEN) as? String
private fun treeContainsArticle(
tree: ContentTreeNodeDto,
chapter: String,
section: String,
article: String,
): Boolean {
val sec =
tree.chapters
.firstOrNull { it.slug == chapter }
?.sections
?.firstOrNull { it.slug == section }
?: return false
return sec.articles.any { it.slug == article }
}
@GetMapping("/learn/course/{courseNo}")
fun index(
@PathVariable courseNo: Long,
session: HttpSession,
model: Model,
): String {
val t = token(session) ?: return "redirect:/login"
val course = api.courseByNo(t, courseNo)
model.addAttribute("course", course)
model.addAttribute("tree", api.courseContentTree(t, course.id))
return "learn/course-index"
}
@Suppress("LongParameterList")
@GetMapping("/learn/course/{courseNo}/{chapter}/{section}/{article}")
fun article(
@PathVariable courseNo: Long,
@PathVariable chapter: String,
@PathVariable section: String,
@PathVariable article: String,
session: HttpSession,
model: Model,
): String {
val t = token(session) ?: return "redirect:/login"
val course = api.courseByNo(t, courseNo)
val tree: ContentTreeNodeDto = api.courseContentTree(t, course.id)
if (!treeContainsArticle(tree, chapter, section, article)) return "redirect:/learn/course/$courseNo"
val a: ContentArticleDto =
try {
api.courseContentArticle(t, course.id, chapter, section, article)
} catch (ignore: ApiException) {
return "redirect:/learn/course/$courseNo"
}
val flat =
tree.chapters.flatMap { c ->
c.sections.flatMap { s ->
s.articles.map { art ->
CourseLearnArticleLink(
chapterSlug = c.slug,
chapterTitle = c.title,
sectionSlug = s.slug,
sectionTitle = s.title,
slug = art.slug,
title = art.title,
)
}
}
}
val index =
flat.indexOfFirst {
it.chapterSlug == chapter && it.sectionSlug == section && it.slug == article
}
model.addAttribute("course", course)
model.addAttribute("tree", tree)
model.addAttribute("article", a.toView(markdown.render(a.bodyMd)))
model.addAttribute("prevArticle", flat.getOrNull(index - 1))
model.addAttribute("nextArticle", flat.getOrNull(index + 1))
return "learn/course-article"
}
}

View File

@@ -59,6 +59,8 @@ class StudentController(
val grade: String?,
val submissionSeq: Int?,
val submissionCount: Int,
val courseNo: Long? = null,
val courseTitle: String? = null,
)
data class StudentAttestationView(
@@ -268,6 +270,8 @@ class StudentController(
grade = grades[lab.id]?.score,
submissionSeq = subs.firstOrNull()?.seq,
submissionCount = subs.size,
courseNo = lab.courseNo,
courseTitle = lab.courseTitle,
)
}
}

View File

@@ -22,6 +22,9 @@
</div>
<span th:text="|Создан: ${course.createdAt}|">Создан: —</span>
· <span th:text="|Обновлён: ${course.updatedAt}|">Обновлён: —</span>
<div class="mt-2">
<a class="btn btn-sm btn-secondary" th:href="@{/learn/course/{no}(no=${course.courseNo})}">Учебные материалы курса</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title th:text="${article.title} + ' — LMS'">Статья курса</title></head>
<body>
<div id="content">
<div class="row g-4">
<div class="col-12 col-lg-3 order-lg-1 order-2">
<aside class="learn-side offcanvas offcanvas-lg offcanvas-end" tabindex="-1" id="articleSide" aria-label="Содержание">
<div class="offcanvas-header d-lg-none">
<span class="offcanvas-title">Содержание</span>
<button type="button" class="navbar-close" data-bs-dismiss="offcanvas" aria-label="Закрыть">✕</button>
</div>
<div class="offcanvas-body p-0">
<div class="card card-body">
<p class="text-sm text-muted mb-2">Содержание</p>
<a class="text-sm font-medium" th:href="@{/learn/course/{no}(no=${course.courseNo})}"
th:text="${course.title}">Курс</a>
<div th:each="c : ${tree.chapters}" class="mt-2">
<div class="side-chapter">
<span th:text="${c.title}">Глава</span>
<span th:if="${!c.published}" class="badge badge-warn">скрыто</span>
</div>
<div th:each="s : ${c.sections}" class="side-section">
<span th:text="${s.title}">Раздел</span>
<span th:if="${!s.published}" class="badge badge-warn">скрыто</span>
<ul class="side-articles">
<li th:each="a : ${s.articles}">
<a th:href="@{/learn/course/{no}/{c2}/{s2}/{a2}(no=${course.courseNo}, c2=${c.slug}, s2=${s.slug}, a2=${a.slug})}"
th:text="${a.title}"
th:classappend="${a.slug == article.slug and s.slug == article.sectionSlug and c.slug == article.chapterSlug} ? 'side-current'">Статья</a>
<span th:if="${!a.published}" class="badge badge-warn">скрыто</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</aside>
</div>
<div class="col-12 col-lg-9 order-1">
<nav class="text-sm mb-4 breadcrumb">
<a th:href="@{/learn/course/{no}(no=${course.courseNo})}" class="nav-link" th:text="${course.title}">Курс</a>
<span class="nav-sep">/</span>
<span class="nav-link" th:text="${article.chapterTitle}">Глава</span>
<span class="nav-sep">/</span>
<span class="nav-link" th:text="${article.sectionTitle}">Раздел</span>
<span class="nav-sep">/</span>
<span th:text="${article.title}">Статья</span>
</nav>
<div class="d-flex align-items-start justify-content-between gap-2 mb-3">
<h1 class="page-title m-0">
<span th:text="${article.title}">Статья</span>
<span th:if="${!article.published}" class="badge badge-warn ml-2">скрыто от студентов</span>
</h1>
<button class="btn btn-secondary btn-sm d-lg-none" type="button"
data-bs-toggle="offcanvas" data-bs-target="#articleSide" aria-controls="articleSide">
Содержание
</button>
</div>
<div class="flex flex-wrap gap-2 mb-4">
<a class="btn btn-secondary btn-sm" th:href="@{/learn/course/{no}(no=${course.courseNo})}">← К материалам курса</a>
</div>
<nav class="article-nav mb-4" th:if="${prevArticle != null or nextArticle != null}">
<a th:if="${prevArticle != null}" class="btn btn-secondary"
th:href="@{/learn/course/{no}/{c}/{s}/{a}(no=${course.courseNo}, c=${prevArticle.chapterSlug}, s=${prevArticle.sectionSlug}, a=${prevArticle.slug})}"
th:title="|${prevArticle.chapterTitle} / ${prevArticle.sectionTitle}|">
← <span th:text="${prevArticle.title}">Предыдущая</span>
</a>
<span th:if="${prevArticle == null}"></span>
<a th:if="${nextArticle != null}" class="btn btn-secondary ml-auto"
th:href="@{/learn/course/{no}/{c}/{s}/{a}(no=${course.courseNo}, c=${nextArticle.chapterSlug}, s=${nextArticle.sectionSlug}, a=${nextArticle.slug})}"
th:title="|${nextArticle.chapterTitle} / ${nextArticle.sectionTitle}|">
<span th:text="${nextArticle.title}">Следующая</span> →
</a>
</nav>
<div id="article-view" class="card card-body md-body" th:utext="${article.bodyHtml}"></div>
<nav class="article-nav mt-6" th:if="${prevArticle != null or nextArticle != null}">
<a th:if="${prevArticle != null}" class="btn btn-secondary"
th:href="@{/learn/course/{no}/{c}/{s}/{a}(no=${course.courseNo}, c=${prevArticle.chapterSlug}, s=${prevArticle.sectionSlug}, a=${prevArticle.slug})}">
← <span th:text="${prevArticle.title}">Предыдущая</span>
</a>
<span th:if="${prevArticle == null}"></span>
<a th:if="${nextArticle != null}" class="btn btn-primary ml-auto"
th:href="@{/learn/course/{no}/{c}/{s}/{a}(no=${course.courseNo}, c=${nextArticle.chapterSlug}, s=${nextArticle.sectionSlug}, a=${nextArticle.slug})}">
<span th:text="${nextArticle.title}">Следующая</span> →
</a>
</nav>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title th:text="${course.title} + ' — LMS'">Материалы курса</title></head>
<body>
<div id="content">
<nav class="text-sm mb-4 breadcrumb">
<a th:href="@{/learn}" class="nav-link">Учебные материалы</a>
<span class="nav-sep">/</span>
<span th:text="${course.title}">Курс</span>
</nav>
<div class="page-head mb-2">
<div>
<h1 class="page-title" th:text="${course.title}">Курс</h1>
<div class="meta">
<span class="badge"
th:classappend="${course.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
th:text="${course.status == 'ACTIVE' ? 'активен' : 'в архиве'}">—</span>
<span th:text="|Курс №${course.courseNo}|">Курс №1</span>
</div>
</div>
</div>
<div th:if="${tree == null || #lists.isEmpty(tree.chapters)}" class="card card-body text-muted mt-4">
В этом курсе пока нет учебных материалов.
</div>
<div th:each="chapter : ${tree?.chapters}" class="card card-body chapter-card mb-4 mt-4">
<h2 class="section-title m-0" th:text="${chapter.title}">Глава</h2>
<span th:if="${!chapter.published}" class="badge badge-warn mt-2">скрыто от студентов</span>
<p th:if="${#lists.isEmpty(chapter.sections)}" class="text-sm text-muted mt-2 mb-0">Разделов пока нет</p>
<div th:each="section : ${chapter.sections}" class="mt-3">
<h3 class="group-title m-0" th:text="${section.title}">Раздел</h3>
<span th:if="${!section.published}" class="badge badge-warn">скрыто от студентов</span>
<ul class="sort-list text-sm mt-2">
<li th:each="article : ${section.articles}">
<a class="sort-title"
th:href="@{/learn/course/{no}/{c}/{s}/{a}(no=${course.courseNo}, c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
th:text="${article.title}">Статья</a>
<span th:if="${!article.published}" class="badge badge-warn">скрыто</span>
</li>
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
</ul>
</div>
</div>
</div>
</body>
</html>

View File

@@ -61,6 +61,9 @@
<span th:unless="${c.submissionSeq}" class="badge badge-warn">не сдано</span>
<span th:if="${c.grade}" class="badge badge-ok">оценка: <span th:text="${c.grade}"></span></span>
</div>
<div class="mt-2" th:if="${c.courseNo != null}">
<a class="text-sm" th:href="@{/learn/course/{no}(no=${c.courseNo})}">Учебные материалы курса</a>
</div>
</div>
</div>
</div>

View File

@@ -21,6 +21,9 @@
<span th:if="${c.deadlineMsk != null and c.defenseMsk != null}" class="nav-sep">·</span>
<span th:if="${c.defenseMsk}" th:text="|Защита: ${c.defenseMsk} МСК|">—</span>
</p>
<div class="mt-2" th:if="${c.courseNo != null}">
<a class="text-sm" th:href="@{/learn/course/{no}(no=${c.courseNo})}">Учебные материалы курса</a>
</div>
</div>
</div>
</div>

View File

@@ -15,6 +15,9 @@
<span th:text="|Групп: ${#lists.size(groups)}|">Групп: 0</span>
· <span th:text="|Лабораторных: ${#lists.size(labs)}|">Лабораторных: 0</span>
</div>
<div class="mt-2">
<a class="btn btn-sm btn-secondary" th:href="@{/learn/course/{no}(no=${course.courseNo})}">Учебные материалы курса</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,123 @@
package ru.ysu.lms.web.learn
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.mock.web.MockHttpServletResponse
import org.springframework.mock.web.MockServletContext
import org.thymeleaf.context.WebContext
import org.thymeleaf.spring6.SpringTemplateEngine
import org.thymeleaf.templatemode.TemplateMode
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
import org.thymeleaf.web.servlet.JakartaServletWebApplication
import ru.ysu.lms.web.api.ContentArticleNodeDto
import ru.ysu.lms.web.api.ContentChapterNodeDto
import ru.ysu.lms.web.api.ContentSectionNodeDto
import ru.ysu.lms.web.api.ContentTreeNodeDto
import ru.ysu.lms.web.api.CourseDetailDto
import java.util.Locale
class CourseLearnTemplateTest {
private val engine =
SpringTemplateEngine().apply {
setTemplateResolver(
ClassLoaderTemplateResolver().apply {
prefix = "templates/"
suffix = ".html"
characterEncoding = "UTF-8"
templateMode = TemplateMode.HTML
},
)
}
private val course = CourseDetailDto(id = "course-id", courseNo = 42, title = "Алгоритмы")
private val tree =
ContentTreeNodeDto(
chapters =
listOf(
ContentChapterNodeDto(
id = "ch1",
title = "Введение",
slug = "intro",
sections =
listOf(
ContentSectionNodeDto(
id = "sec1",
title = "Основы",
slug = "basics",
articles =
listOf(
ContentArticleNodeDto(
id = "art1",
title = "Первый шаг",
slug = "first-step",
),
),
),
),
),
),
)
@Test
fun `course overview links to course article pages`() {
val html = render("learn/course-index", mapOf("course" to course, "tree" to tree))
assertThat(html).contains("Алгоритмы")
assertThat(html).contains("Введение")
assertThat(html).contains("Основы")
assertThat(html).contains("Первый шаг")
assertThat(html).contains("/learn/course/42/intro/basics/first-step")
}
@Test
fun `course article shows breadcrumbs and back link`() {
val article =
LearnArticleView(
id = "art1",
chapterSlug = "intro",
chapterTitle = "Введение",
sectionSlug = "basics",
sectionTitle = "Основы",
slug = "first-step",
title = "Первый шаг",
bodyHtml = "<p>Текст статьи</p>",
bodyMd = "Текст статьи",
published = true,
)
val html =
render(
"learn/course-article",
mapOf(
"course" to course,
"tree" to tree,
"article" to article,
"prevArticle" to null,
"nextArticle" to null,
),
)
assertThat(html).contains("Текст статьи")
assertThat(html).contains("/learn/course/42")
assertThat(html).contains("Введение")
assertThat(html).contains("Основы")
assertThat(html).contains("← К материалам курса")
}
private fun render(
template: String,
variables: Map<String, Any?>,
): String {
val servletContext = MockServletContext()
val application = JakartaServletWebApplication.buildApplication(servletContext)
val exchange =
application.buildExchange(
MockHttpServletRequest(servletContext),
MockHttpServletResponse(),
)
val context = WebContext(exchange, Locale.forLanguageTag("ru"))
variables.forEach { (name, value) -> context.setVariable(name, value) }
return engine.process(template, context)
}
}