From 0b5d5d0ca51c57d343839f9f4c3225a19b69270b Mon Sep 17 00:00:00 2001
From: Vladislav Kopnin
Date: Tue, 27 Oct 2026 19:01:30 +0300
Subject: [PATCH] =?UTF-8?q?feat(web):=20=D1=81=D1=82=D1=80=D0=B0=D0=BD?=
=?UTF-8?q?=D0=B8=D1=86=D1=8B=20=D0=BC=D0=B0=D1=82=D0=B5=D1=80=D0=B8=D0=B0?=
=?UTF-8?q?=D0=BB=D0=BE=D0=B2=20=D0=BA=D1=83=D1=80=D1=81=D0=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ru/ysu/lms/web/api/ApiClientContent.kt | 30 +++++
.../ru/ysu/lms/web/api/ApiClientCourse.kt | 14 ++
.../kotlin/ru/ysu/lms/web/config/WebConfig.kt | 2 +-
.../lms/web/learn/CourseLearnController.kt | 116 +++++++++++++++++
.../ysu/lms/web/student/StudentController.kt | 4 +
.../resources/templates/admin/course.html | 3 +
.../templates/learn/course-article.html | 98 ++++++++++++++
.../templates/learn/course-index.html | 50 +++++++
.../templates/student/dashboard.html | 3 +
.../resources/templates/student/labs.html | 3 +
.../resources/templates/teacher/course.html | 3 +
.../lms/web/learn/CourseLearnTemplateTest.kt | 123 ++++++++++++++++++
12 files changed, 448 insertions(+), 1 deletion(-)
create mode 100644 web/src/main/kotlin/ru/ysu/lms/web/learn/CourseLearnController.kt
create mode 100644 web/src/main/resources/templates/learn/course-article.html
create mode 100644 web/src/main/resources/templates/learn/course-index.html
create mode 100644 web/src/test/kotlin/ru/ysu/lms/web/learn/CourseLearnTemplateTest.kt
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt
index 860f947..4ff0538 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt
@@ -43,6 +43,36 @@ fun ApiClient.contentTreeList(token: String?): List {
}
}
+/** Дерево материалов курса: доступно назначенным преподавателям, студентам групп курса и админу. */
+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,
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt
index 28fce70..3e76e6e 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt
@@ -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,
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt b/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt
index 8844f66..bd95d40 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt
@@ -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/**")
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/learn/CourseLearnController.kt b/web/src/main/kotlin/ru/ysu/lms/web/learn/CourseLearnController.kt
new file mode 100644
index 0000000..125e008
--- /dev/null
+++ b/web/src/main/kotlin/ru/ysu/lms/web/learn/CourseLearnController.kt
@@ -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"
+ }
+}
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt b/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt
index e0ee807..8082e21 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt
@@ -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,
)
}
}
diff --git a/web/src/main/resources/templates/admin/course.html b/web/src/main/resources/templates/admin/course.html
index 3681204..1ac9e2f 100644
--- a/web/src/main/resources/templates/admin/course.html
+++ b/web/src/main/resources/templates/admin/course.html
@@ -22,6 +22,9 @@
Создан: —
· Обновлён: —
+
diff --git a/web/src/main/resources/templates/learn/course-article.html b/web/src/main/resources/templates/learn/course-article.html
new file mode 100644
index 0000000..816dad7
--- /dev/null
+++ b/web/src/main/resources/templates/learn/course-article.html
@@ -0,0 +1,98 @@
+
+
+Статья курса
+
+
+
+
diff --git a/web/src/main/resources/templates/learn/course-index.html b/web/src/main/resources/templates/learn/course-index.html
new file mode 100644
index 0000000..eba770e
--- /dev/null
+++ b/web/src/main/resources/templates/learn/course-index.html
@@ -0,0 +1,50 @@
+
+
+Материалы курса
+
+
+
+
+
+
+
Курс
+
+ —
+ Курс №1
+
+
+
+
+
+ В этом курсе пока нет учебных материалов.
+
+
+
+
Глава
+
скрыто от студентов
+
+
Разделов пока нет
+
+
+
Раздел
+
скрыто от студентов
+
+ -
+ Статья
+ скрыто
+
+ - Статей пока нет
+
+
+
+
+
+
diff --git a/web/src/main/resources/templates/student/dashboard.html b/web/src/main/resources/templates/student/dashboard.html
index 2043eb3..631416f 100644
--- a/web/src/main/resources/templates/student/dashboard.html
+++ b/web/src/main/resources/templates/student/dashboard.html
@@ -61,6 +61,9 @@
не сдано
оценка:
+
diff --git a/web/src/main/resources/templates/student/labs.html b/web/src/main/resources/templates/student/labs.html
index 9e9605f..d43ba76 100644
--- a/web/src/main/resources/templates/student/labs.html
+++ b/web/src/main/resources/templates/student/labs.html
@@ -21,6 +21,9 @@
·
—
+
diff --git a/web/src/main/resources/templates/teacher/course.html b/web/src/main/resources/templates/teacher/course.html
index 29c0d20..7ab8c18 100644
--- a/web/src/main/resources/templates/teacher/course.html
+++ b/web/src/main/resources/templates/teacher/course.html
@@ -15,6 +15,9 @@
Групп: 0
· Лабораторных: 0
+
diff --git a/web/src/test/kotlin/ru/ysu/lms/web/learn/CourseLearnTemplateTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/learn/CourseLearnTemplateTest.kt
new file mode 100644
index 0000000..590a750
--- /dev/null
+++ b/web/src/test/kotlin/ru/ysu/lms/web/learn/CourseLearnTemplateTest.kt
@@ -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 = "Текст статьи
",
+ 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 {
+ 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)
+ }
+}