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) + } +}