feat(academic): курсовые материалы и публичный ALL-контент
This commit is contained in:
@@ -23,6 +23,7 @@ dependencies {
|
|||||||
implementation(project(":libs:security-jwt"))
|
implementation(project(":libs:security-jwt"))
|
||||||
|
|
||||||
testImplementation(project(":libs:test-support"))
|
testImplementation(project(":libs:test-support"))
|
||||||
|
testImplementation(libs.springSecurityTest)
|
||||||
}
|
}
|
||||||
|
|
||||||
springBoot {
|
springBoot {
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package ru.ysu.lms.academic.content
|
||||||
|
|
||||||
|
/** Аудитория глобальной главы, доступной без авторизации. */
|
||||||
|
const val AUDIENCE_ALL = "ALL"
|
||||||
@@ -56,7 +56,15 @@ class ContentAuthoringService(
|
|||||||
slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
|
slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
|
||||||
?: Slugs.uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
|
?: Slugs.uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
|
||||||
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
|
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
|
||||||
val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1))
|
val ch =
|
||||||
|
chapters.save(
|
||||||
|
ContentChapter(
|
||||||
|
title = title.trim(),
|
||||||
|
slug = cleanSlug,
|
||||||
|
position = maxPos + 1,
|
||||||
|
audience = AUDIENCE_ALL,
|
||||||
|
),
|
||||||
|
)
|
||||||
return ChapterTreeNode(
|
return ChapterTreeNode(
|
||||||
id = ch.id.toString(),
|
id = ch.id.toString(),
|
||||||
title = ch.title,
|
title = ch.title,
|
||||||
|
|||||||
@@ -85,10 +85,13 @@ class ContentImporter(
|
|||||||
existing?.apply {
|
existing?.apply {
|
||||||
title = p.chapterDir
|
title = p.chapterDir
|
||||||
position = p.chapterPos
|
position = p.chapterPos
|
||||||
|
// Импорт создаёт/обновляет глобальные главы; курсовые не трогаем.
|
||||||
|
if (courseId == null && audience == null) audience = AUDIENCE_ALL
|
||||||
} ?: ContentChapter(
|
} ?: ContentChapter(
|
||||||
title = p.chapterDir,
|
title = p.chapterDir,
|
||||||
slug = Slugs.uniqueSlug(p.chapterDir, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()),
|
slug = Slugs.uniqueSlug(p.chapterDir, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()),
|
||||||
position = p.chapterPos,
|
position = p.chapterPos,
|
||||||
|
audience = AUDIENCE_ALL,
|
||||||
)
|
)
|
||||||
state.chapter = chapters.save(entity)
|
state.chapter = chapters.save(entity)
|
||||||
state.touchedChapters.add(state.chapter!!.id)
|
state.touchedChapters.add(state.chapter!!.id)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package ru.ysu.lms.academic.content
|
|||||||
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import ru.ysu.lms.academic.domain.ContentChapter
|
||||||
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
||||||
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
||||||
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||||
@@ -99,18 +100,122 @@ class ContentService(
|
|||||||
private val articles: ContentArticleRepository,
|
private val articles: ContentArticleRepository,
|
||||||
private val importer: ContentImporter,
|
private val importer: ContentImporter,
|
||||||
) {
|
) {
|
||||||
|
/** Публичное дерево: только глобальные главы с аудиторией ALL. */
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun tree(includeHidden: Boolean = false): ContentTree {
|
fun tree(includeHidden: Boolean = false): ContentTree =
|
||||||
val chapterList = chapters.findAllByOrderByPositionAscIdAsc().filter { includeHidden || it.published }
|
buildTree(
|
||||||
|
chapters
|
||||||
|
.findAllByOrderByPositionAscIdAsc()
|
||||||
|
.filter {
|
||||||
|
it.courseId == null &&
|
||||||
|
it.audience == AUDIENCE_ALL &&
|
||||||
|
(includeHidden || it.published)
|
||||||
|
},
|
||||||
|
includeHidden = includeHidden,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Полное дерево (включая скрытые и курсовые главы) — только для админки. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun treeAll(): ContentTree = buildTree(chapters.findAllByOrderByPositionAscIdAsc(), includeHidden = true)
|
||||||
|
|
||||||
|
/** Дерево материалов конкретного курса. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun courseTree(
|
||||||
|
courseId: UUID,
|
||||||
|
includeHidden: Boolean = false,
|
||||||
|
): ContentTree =
|
||||||
|
buildTree(
|
||||||
|
chapters.findByCourseIdOrderByPositionAscIdAsc(courseId).filter { includeHidden || it.published },
|
||||||
|
includeHidden = includeHidden,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun findArticle(
|
||||||
|
chapterSlug: String,
|
||||||
|
sectionSlug: String,
|
||||||
|
articleSlug: String,
|
||||||
|
includeHidden: Boolean = false,
|
||||||
|
): ContentArticleView {
|
||||||
|
val ch = requireFound(chapters.findBySlugAndCourseIdIsNull(chapterSlug), "Глава не найдена")
|
||||||
|
requirePublicChapter(ch, includeHidden)
|
||||||
|
return articleView(ch, sectionSlug, articleSlug, includeHidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Просмотр статьи админом: глобальные и курсовые главы без учёта публикации. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun findArticleForAdmin(
|
||||||
|
chapterSlug: String,
|
||||||
|
sectionSlug: String,
|
||||||
|
articleSlug: String,
|
||||||
|
): ContentArticleView {
|
||||||
|
// Сначала глобальная глава: findBySlug может упасть на коллизии slug между курсами.
|
||||||
|
val ch =
|
||||||
|
requireFound(
|
||||||
|
chapters.findBySlugAndCourseIdIsNull(chapterSlug) ?: chapters.findBySlug(chapterSlug),
|
||||||
|
"Глава не найдена",
|
||||||
|
)
|
||||||
|
return articleView(ch, sectionSlug, articleSlug, includeHidden = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun findCourseArticle(
|
||||||
|
courseId: UUID,
|
||||||
|
chapterSlug: String,
|
||||||
|
sectionSlug: String,
|
||||||
|
articleSlug: String,
|
||||||
|
includeHidden: Boolean = false,
|
||||||
|
): ContentArticleView {
|
||||||
|
val ch = requireFound(chapters.findByCourseIdAndSlug(courseId, chapterSlug), "Глава не найдена")
|
||||||
|
requireVisible(ch.published, includeHidden, "Глава скрыта")
|
||||||
|
return articleView(ch, sectionSlug, articleSlug, includeHidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun articleView(
|
||||||
|
ch: ContentChapter,
|
||||||
|
sectionSlug: String,
|
||||||
|
articleSlug: String,
|
||||||
|
includeHidden: Boolean,
|
||||||
|
): ContentArticleView {
|
||||||
|
val sec = requireFound(sections.findByChapterIdAndSlug(ch.id, sectionSlug), "Раздел не найден")
|
||||||
|
requireVisible(sec.published, includeHidden, "Раздел скрыт")
|
||||||
|
val a = requireFound(articles.findBySectionIdAndSlug(sec.id, articleSlug), "Статья не найдена")
|
||||||
|
requireVisible(a.published, includeHidden, "Статья скрыта")
|
||||||
|
return ContentArticleView(
|
||||||
|
id = a.id.toString(),
|
||||||
|
chapterSlug = ch.slug,
|
||||||
|
chapterTitle = ch.title,
|
||||||
|
sectionSlug = sec.slug,
|
||||||
|
sectionTitle = sec.title,
|
||||||
|
slug = a.slug,
|
||||||
|
title = a.title,
|
||||||
|
bodyMd = a.bodyMd,
|
||||||
|
published = a.published,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requirePublicChapter(
|
||||||
|
ch: ContentChapter,
|
||||||
|
includeHidden: Boolean,
|
||||||
|
) {
|
||||||
|
requireVisible(ch.published, includeHidden, "Глава скрыта")
|
||||||
|
if (!includeHidden && ch.audience != AUDIENCE_ALL) throw NotFoundException("Глава недоступна")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildTree(
|
||||||
|
chapterList: List<ContentChapter>,
|
||||||
|
includeHidden: Boolean,
|
||||||
|
): ContentTree {
|
||||||
|
val chapterIds = chapterList.mapTo(HashSet()) { it.id }
|
||||||
val sectionList =
|
val sectionList =
|
||||||
sections
|
sections
|
||||||
.findAllByOrderByPositionAscIdAsc()
|
.findAllByOrderByPositionAscIdAsc()
|
||||||
.filter { includeHidden || it.published }
|
.filter { it.chapterId in chapterIds && (includeHidden || it.published) }
|
||||||
.groupBy { it.chapterId }
|
.groupBy { it.chapterId }
|
||||||
|
val sectionIds = sectionList.values.flatten().mapTo(HashSet()) { it.id }
|
||||||
val articleList =
|
val articleList =
|
||||||
articles
|
articles
|
||||||
.findAllByOrderByPositionAscIdAsc()
|
.findAllByOrderByPositionAscIdAsc()
|
||||||
.filter { includeHidden || it.published }
|
.filter { it.sectionId in sectionIds && (includeHidden || it.published) }
|
||||||
.groupBy { it.sectionId }
|
.groupBy { it.sectionId }
|
||||||
return ContentTree(
|
return ContentTree(
|
||||||
chapterList.map { ch ->
|
chapterList.map { ch ->
|
||||||
@@ -139,36 +244,6 @@ class ContentService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Полное дерево (включая скрытые) — только для админки. */
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun treeAll(): ContentTree = tree(includeHidden = true)
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun findArticle(
|
|
||||||
chapterSlug: String,
|
|
||||||
sectionSlug: String,
|
|
||||||
articleSlug: String,
|
|
||||||
includeHidden: Boolean = false,
|
|
||||||
): ContentArticleView {
|
|
||||||
val ch = requireFound(chapters.findBySlug(chapterSlug), "Глава не найдена")
|
|
||||||
requireVisible(ch.published, includeHidden, "Глава скрыта")
|
|
||||||
val sec = requireFound(sections.findByChapterIdAndSlug(ch.id, sectionSlug), "Раздел не найден")
|
|
||||||
requireVisible(sec.published, includeHidden, "Раздел скрыт")
|
|
||||||
val a = requireFound(articles.findBySectionIdAndSlug(sec.id, articleSlug), "Статья не найдена")
|
|
||||||
requireVisible(a.published, includeHidden, "Статья скрыта")
|
|
||||||
return ContentArticleView(
|
|
||||||
id = a.id.toString(),
|
|
||||||
chapterSlug = ch.slug,
|
|
||||||
chapterTitle = ch.title,
|
|
||||||
sectionSlug = sec.slug,
|
|
||||||
sectionTitle = sec.title,
|
|
||||||
slug = a.slug,
|
|
||||||
title = a.title,
|
|
||||||
bodyMd = a.bodyMd,
|
|
||||||
published = a.published,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun <T> requireFound(
|
private fun <T> requireFound(
|
||||||
value: T?,
|
value: T?,
|
||||||
message: String,
|
message: String,
|
||||||
|
|||||||
@@ -72,20 +72,10 @@ class ContentAdminController(
|
|||||||
@PathVariable chapter: String,
|
@PathVariable chapter: String,
|
||||||
@PathVariable section: String,
|
@PathVariable section: String,
|
||||||
@PathVariable article: String,
|
@PathVariable article: String,
|
||||||
): ContentPublicController.ContentArticleViewDto {
|
): ContentPublicController.ContentArticleViewDto =
|
||||||
val a = contentService.findArticle(chapter, section, article, includeHidden = true)
|
contentService
|
||||||
return ContentPublicController.ContentArticleViewDto(
|
.findArticleForAdmin(chapter, section, article)
|
||||||
id = a.id,
|
.toDto()
|
||||||
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")
|
@PutMapping("/chapters/{id}/published")
|
||||||
fun chapterPublished(
|
fun chapterPublished(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.PathVariable
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import ru.ysu.lms.academic.content.AssetStorage
|
import ru.ysu.lms.academic.content.AssetStorage
|
||||||
|
import ru.ysu.lms.academic.content.ContentArticleView
|
||||||
import ru.ysu.lms.academic.content.ContentService
|
import ru.ysu.lms.academic.content.ContentService
|
||||||
|
|
||||||
/** Публичное чтение учебных материалов — доступно без аутентификации. */
|
/** Публичное чтение учебных материалов — доступно без аутентификации. */
|
||||||
@@ -25,20 +26,7 @@ class ContentPublicController(
|
|||||||
@PathVariable chapter: String,
|
@PathVariable chapter: String,
|
||||||
@PathVariable section: String,
|
@PathVariable section: String,
|
||||||
@PathVariable article: String,
|
@PathVariable article: String,
|
||||||
): ContentArticleViewDto {
|
): ContentArticleViewDto = contentService.findArticle(chapter, section, article).toDto()
|
||||||
val a = contentService.findArticle(chapter, section, article)
|
|
||||||
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,
|
|
||||||
published = a.published,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class ContentArticleViewDto(
|
data class ContentArticleViewDto(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -76,3 +64,16 @@ class ContentPublicController(
|
|||||||
return builder.body(FileSystemResource(path))
|
return builder.body(FileSystemResource(path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun ContentArticleView.toDto(): ContentPublicController.ContentArticleViewDto =
|
||||||
|
ContentPublicController.ContentArticleViewDto(
|
||||||
|
id = id,
|
||||||
|
chapterSlug = chapterSlug,
|
||||||
|
chapterTitle = chapterTitle,
|
||||||
|
sectionSlug = sectionSlug,
|
||||||
|
sectionTitle = sectionTitle,
|
||||||
|
slug = slug,
|
||||||
|
title = title,
|
||||||
|
bodyMd = bodyMd,
|
||||||
|
published = published,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package ru.ysu.lms.academic.controller
|
||||||
|
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import ru.ysu.lms.academic.content.ContentService
|
||||||
|
import ru.ysu.lms.academic.content.ContentTree
|
||||||
|
import ru.ysu.lms.academic.course.CourseAccessService
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Материалы курса. Доступ проверяется по курсовой области видимости:
|
||||||
|
* администратор — любой курс, преподаватель — назначенный, студент — курс своей группы.
|
||||||
|
* Скрытые главы/разделы/статьи видят только администратор и преподаватель курса.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/courses/{courseId}/content")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
class CourseContentController(
|
||||||
|
private val contentService: ContentService,
|
||||||
|
private val accessService: CourseAccessService,
|
||||||
|
) {
|
||||||
|
@GetMapping("/tree")
|
||||||
|
fun tree(
|
||||||
|
@PathVariable courseId: UUID,
|
||||||
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
): ContentTree {
|
||||||
|
accessService.requireCourseAccess(courseId, jwt)
|
||||||
|
return contentService.courseTree(courseId, includeHidden = canSeeHidden(jwt))
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{chapter}/{section}/{article}")
|
||||||
|
fun article(
|
||||||
|
@PathVariable courseId: UUID,
|
||||||
|
@PathVariable chapter: String,
|
||||||
|
@PathVariable section: String,
|
||||||
|
@PathVariable article: String,
|
||||||
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
): ContentPublicController.ContentArticleViewDto {
|
||||||
|
accessService.requireCourseAccess(courseId, jwt)
|
||||||
|
return contentService
|
||||||
|
.findCourseArticle(courseId, chapter, section, article, includeHidden = canSeeHidden(jwt))
|
||||||
|
.toDto()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canSeeHidden(jwt: Jwt): Boolean =
|
||||||
|
jwt.getClaimAsString("role") in setOf(CourseAccessService.ROLE_ADMIN, CourseAccessService.ROLE_TEACHER)
|
||||||
|
}
|
||||||
@@ -62,6 +62,17 @@ class CourseController(
|
|||||||
return courseService.detail(id)
|
return courseService.detail(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Курс по человекочитаемому номеру из URL (`course_no`). */
|
||||||
|
@GetMapping("/by-no/{courseNo}")
|
||||||
|
fun detailByNo(
|
||||||
|
@PathVariable courseNo: Long,
|
||||||
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
): CourseDetail {
|
||||||
|
val course = courseService.findByCourseNo(courseNo)
|
||||||
|
accessService.requireCourseAccess(UUID.fromString(course.id), jwt)
|
||||||
|
return course
|
||||||
|
}
|
||||||
|
|
||||||
/** Состав группы, активно привязанной к курсу. */
|
/** Состав группы, активно привязанной к курсу. */
|
||||||
@GetMapping("/{courseId}/groups/{groupId}/students")
|
@GetMapping("/{courseId}/groups/{groupId}/students")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ class CourseService(
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun detail(courseId: UUID): CourseDetail = toDetail(findCourse(courseId))
|
fun detail(courseId: UUID): CourseDetail = toDetail(findCourse(courseId))
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun findByCourseNo(courseNo: Long): CourseDetail =
|
||||||
|
toDetail(courses.findByCourseNo(courseNo) ?: throw NotFoundException("Курс не найден"))
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun update(
|
fun update(
|
||||||
courseId: UUID,
|
courseId: UUID,
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ interface ContentChapterRepository : JpaRepository<ContentChapter, UUID> {
|
|||||||
|
|
||||||
fun findBySlug(slug: String): ContentChapter?
|
fun findBySlug(slug: String): ContentChapter?
|
||||||
|
|
||||||
|
fun findBySlugAndCourseIdIsNull(slug: String): ContentChapter?
|
||||||
|
|
||||||
|
fun findByCourseIdOrderByPositionAscIdAsc(courseId: UUID): List<ContentChapter>
|
||||||
|
|
||||||
|
fun findByCourseIdAndSlug(
|
||||||
|
courseId: UUID,
|
||||||
|
slug: String,
|
||||||
|
): ContentChapter?
|
||||||
|
|
||||||
fun findByTitleIgnoreCase(title: String): ContentChapter?
|
fun findByTitleIgnoreCase(title: String): ContentChapter?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import java.util.UUID
|
|||||||
interface CourseRepository : JpaRepository<Course, UUID> {
|
interface CourseRepository : JpaRepository<Course, UUID> {
|
||||||
fun findAllByOrderByCourseNoAsc(): List<Course>
|
fun findAllByOrderByCourseNoAsc(): List<Course>
|
||||||
|
|
||||||
|
fun findByCourseNo(courseNo: Long): Course?
|
||||||
|
|
||||||
fun findByTitleIgnoreCase(title: String): Course?
|
fun findByTitleIgnoreCase(title: String): Course?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Глобальные главы (course_id IS NULL) должны быть публично доступны.
|
||||||
|
-- Backfill: старые глобальные главы без аудитории получают ALL.
|
||||||
|
-- Курсовые главы остаются с audience = NULL: ограничение chapters_course_audience_check.
|
||||||
|
UPDATE academic.chapters
|
||||||
|
SET audience = 'ALL'
|
||||||
|
WHERE course_id IS NULL
|
||||||
|
AND audience IS NULL;
|
||||||
@@ -72,6 +72,7 @@ class ContentImporterTest {
|
|||||||
assertThat(result.articles).isEqualTo(1)
|
assertThat(result.articles).isEqualTo(1)
|
||||||
assertThat(savedChapter.captured.title).isEqualTo("Глава")
|
assertThat(savedChapter.captured.title).isEqualTo("Глава")
|
||||||
assertThat(savedChapter.captured.slug).isEqualTo("glava")
|
assertThat(savedChapter.captured.slug).isEqualTo("glava")
|
||||||
|
assertThat(savedChapter.captured.audience).isEqualTo(AUDIENCE_ALL)
|
||||||
assertThat(savedArticle.captured.title).isEqualTo("Ввод-вывод")
|
assertThat(savedArticle.captured.title).isEqualTo("Ввод-вывод")
|
||||||
assertThat(savedArticle.captured.slug).isEqualTo("c-input")
|
assertThat(savedArticle.captured.slug).isEqualTo("c-input")
|
||||||
}
|
}
|
||||||
@@ -92,5 +93,46 @@ class ContentImporterTest {
|
|||||||
|
|
||||||
assertThat(result.chapters).isEqualTo(1)
|
assertThat(result.chapters).isEqualTo(1)
|
||||||
assertThat(chapterSaves).containsExactly(chapter.id)
|
assertThat(chapterSaves).containsExactly(chapter.id)
|
||||||
|
assertThat(chapter.audience).isEqualTo(AUDIENCE_ALL)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `does not overwrite explicit global audience`() {
|
||||||
|
val importer = importer()
|
||||||
|
val chapter =
|
||||||
|
ContentChapter(
|
||||||
|
title = "Глава",
|
||||||
|
slug = "glava",
|
||||||
|
position = 1,
|
||||||
|
audience = "STUDENTS",
|
||||||
|
)
|
||||||
|
val section = ContentSection(chapterId = chapter.id, title = "Раздел", slug = "razdel", position = 1)
|
||||||
|
every { chapters.findByTitleIgnoreCase("Глава") } returns chapter
|
||||||
|
every { sections.findByChapterIdAndTitleIgnoreCase(chapter.id, "Раздел") } returns section
|
||||||
|
|
||||||
|
importer.importZip("content.zip", articleZip("Тема", "c-topic"), sync = false)
|
||||||
|
|
||||||
|
assertThat(chapter.audience).isEqualTo("STUDENTS")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `does not give ALL audience to course chapter`() {
|
||||||
|
val importer = importer()
|
||||||
|
val courseId = UUID.randomUUID()
|
||||||
|
val chapter =
|
||||||
|
ContentChapter(
|
||||||
|
title = "Глава",
|
||||||
|
slug = "glava",
|
||||||
|
position = 1,
|
||||||
|
courseId = courseId,
|
||||||
|
)
|
||||||
|
val section = ContentSection(chapterId = chapter.id, title = "Раздел", slug = "razdel", position = 1)
|
||||||
|
every { chapters.findByTitleIgnoreCase("Глава") } returns chapter
|
||||||
|
every { sections.findByChapterIdAndTitleIgnoreCase(chapter.id, "Раздел") } returns section
|
||||||
|
|
||||||
|
importer.importZip("content.zip", articleZip("Тема", "c-topic"), sync = false)
|
||||||
|
|
||||||
|
assertThat(chapter.courseId).isEqualTo(courseId)
|
||||||
|
assertThat(chapter.audience).isNull()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
package ru.ysu.lms.academic.content
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.ArgumentMatchers.anyString
|
||||||
|
import org.mockito.Mockito.`when`
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt
|
||||||
|
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry
|
||||||
|
import org.springframework.test.context.DynamicPropertySource
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.web.context.WebApplicationContext
|
||||||
|
import org.testcontainers.junit.jupiter.Container
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers
|
||||||
|
import org.testcontainers.postgresql.PostgreSQLContainer
|
||||||
|
import ru.ysu.lms.academic.course.CourseCreateRequest
|
||||||
|
import ru.ysu.lms.academic.course.CourseGroupRequest
|
||||||
|
import ru.ysu.lms.academic.course.CourseService
|
||||||
|
import ru.ysu.lms.academic.course.CourseTeacherRequest
|
||||||
|
import ru.ysu.lms.academic.domain.ContentArticle
|
||||||
|
import ru.ysu.lms.academic.domain.ContentChapter
|
||||||
|
import ru.ysu.lms.academic.domain.ContentSection
|
||||||
|
import ru.ysu.lms.academic.identity.IdentityClient
|
||||||
|
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
||||||
|
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
||||||
|
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Курсовые материалы: публичное дерево не отдаёт главы курса, а доступ к материалам
|
||||||
|
* курса проверяется по роли и назначению. Внешний identity подменён.
|
||||||
|
*/
|
||||||
|
@Testcontainers
|
||||||
|
@SpringBootTest
|
||||||
|
@Transactional
|
||||||
|
class CourseContentIntegrationTest {
|
||||||
|
companion object {
|
||||||
|
@Container
|
||||||
|
@ServiceConnection
|
||||||
|
@JvmStatic
|
||||||
|
val postgres = PostgreSQLContainer("postgres:18")
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
@DynamicPropertySource
|
||||||
|
fun assetsDir(registry: DynamicPropertyRegistry) {
|
||||||
|
registry.add("lms.content.assets-dir") { Files.createTempDirectory("lms-assets").toString() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var context: WebApplicationContext
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var contentService: ContentService
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var authoring: ContentAuthoringService
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var courseService: CourseService
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var chapters: ContentChapterRepository
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var sections: ContentSectionRepository
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var articles: ContentArticleRepository
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private lateinit var identity: IdentityClient
|
||||||
|
|
||||||
|
private val mockMvc: MockMvc by lazy {
|
||||||
|
MockMvcBuilders
|
||||||
|
.webAppContextSetup(context)
|
||||||
|
.apply<DefaultMockMvcBuilder>(springSecurity())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `public tree contains only global chapters with ALL audience`() {
|
||||||
|
val global = seedChapter(title = "Глобальная", courseId = null, audience = "ALL")
|
||||||
|
seedChapter(title = "Только студентам", courseId = null, audience = "STUDENTS")
|
||||||
|
val course = newCourse()
|
||||||
|
val courseChapter = seedChapter(title = "Курсовая", courseId = course.id, audience = null)
|
||||||
|
|
||||||
|
assertThat(courseChapter.audience).isNull()
|
||||||
|
assertThat(contentService.tree().chapters.map { it.slug }).containsExactly(global.slug)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/v1/content/tree"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.chapters.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.chapters[0].slug").value(global.slug))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `newly created global chapter gets ALL audience and appears in public tree`() {
|
||||||
|
val node = authoring.createChapter("Публичная глава ${UUID.randomUUID()}", slug = null)
|
||||||
|
val saved = chapters.findById(UUID.fromString(node.id)).orElseThrow()
|
||||||
|
|
||||||
|
assertThat(saved.courseId).isNull()
|
||||||
|
assertThat(saved.audience).isEqualTo(AUDIENCE_ALL)
|
||||||
|
assertThat(contentService.tree().chapters.map { it.slug }).contains(node.slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `anonymous cannot read course content`() {
|
||||||
|
val course = newCourse()
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/v1/courses/${course.id}/content/tree"))
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `course chapter is not exposed through public article endpoint`() {
|
||||||
|
val course = newCourse()
|
||||||
|
val material = seedMaterial(courseId = course.id, published = true)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
get("/api/v1/content/${material.chapter.slug}/${material.section.slug}/${material.article.slug}"),
|
||||||
|
).andExpect(status().isNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `admin preview still includes course chapters and hidden articles`() {
|
||||||
|
val course = newCourse()
|
||||||
|
val material = seedMaterial(courseId = course.id, published = false)
|
||||||
|
|
||||||
|
assertThat(contentService.treeAll().chapters.map { it.slug }).contains(material.chapter.slug)
|
||||||
|
assertThat(contentService.findArticleForAdmin(material.chapter.slug, material.section.slug, material.article.slug).title)
|
||||||
|
.isEqualTo(material.article.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `teacher of course sees published and hidden materials`() {
|
||||||
|
val course = newCourse()
|
||||||
|
val teacherId = UUID.randomUUID()
|
||||||
|
courseService.assignTeacher(course.id, CourseTeacherRequest(teacherId.toString()))
|
||||||
|
val published = seedMaterial(courseId = course.id, published = true)
|
||||||
|
val hidden = seedMaterial(courseId = course.id, published = false)
|
||||||
|
|
||||||
|
val tree = contentService.courseTree(course.id, includeHidden = true)
|
||||||
|
assertThat(tree.chapters.map { it.slug }).containsExactly(published.chapter.slug, hidden.chapter.slug)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
get("/api/v1/courses/${course.id}/content/tree")
|
||||||
|
.with(jwtFor(teacherId, "TEACHER")),
|
||||||
|
).andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.chapters.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.chapters[1].published").value(false))
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
get(
|
||||||
|
"/api/v1/courses/${course.id}/content/" +
|
||||||
|
"${hidden.chapter.slug}/${hidden.section.slug}/${hidden.article.slug}",
|
||||||
|
).with(jwtFor(teacherId, "TEACHER")),
|
||||||
|
).andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.title").value(hidden.article.title))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `student of course sees only published materials`() {
|
||||||
|
val course = newCourse()
|
||||||
|
val groupId = UUID.randomUUID()
|
||||||
|
courseService.attachGroup(course.id, CourseGroupRequest(groupId.toString()))
|
||||||
|
val studentId = UUID.randomUUID()
|
||||||
|
`when`(identity.currentGroupIds(anyString())).thenReturn(listOf(groupId))
|
||||||
|
val published = seedMaterial(courseId = course.id, published = true)
|
||||||
|
val hidden = seedMaterial(courseId = course.id, published = false)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
get("/api/v1/courses/${course.id}/content/tree")
|
||||||
|
.with(jwtFor(studentId, "STUDENT")),
|
||||||
|
).andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.chapters.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.chapters[0].slug").value(published.chapter.slug))
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
get(
|
||||||
|
"/api/v1/courses/${course.id}/content/" +
|
||||||
|
"${hidden.chapter.slug}/${hidden.section.slug}/${hidden.article.slug}",
|
||||||
|
).with(jwtFor(studentId, "STUDENT")),
|
||||||
|
).andExpect(status().isNotFound)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
get(
|
||||||
|
"/api/v1/courses/${course.id}/content/" +
|
||||||
|
"${published.chapter.slug}/${published.section.slug}/${published.article.slug}",
|
||||||
|
).with(jwtFor(studentId, "STUDENT")),
|
||||||
|
).andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.title").value(published.article.title))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `foreign teacher is forbidden for course content and course by number`() {
|
||||||
|
val course = newCourse()
|
||||||
|
val foreignTeacher = UUID.randomUUID()
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
get("/api/v1/courses/${course.id}/content/tree")
|
||||||
|
.with(jwtFor(foreignTeacher, "TEACHER")),
|
||||||
|
).andExpect(status().isForbidden)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
get("/api/v1/courses/by-no/${course.courseNo}")
|
||||||
|
.with(jwtFor(foreignTeacher, "TEACHER")),
|
||||||
|
).andExpect(status().isForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `course is available by number to its teacher`() {
|
||||||
|
val course = newCourse()
|
||||||
|
val teacherId = UUID.randomUUID()
|
||||||
|
courseService.assignTeacher(course.id, CourseTeacherRequest(teacherId.toString()))
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
get("/api/v1/courses/by-no/${course.courseNo}")
|
||||||
|
.with(jwtFor(teacherId, "TEACHER")),
|
||||||
|
).andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.id").value(course.id.toString()))
|
||||||
|
.andExpect(jsonPath("$.courseNo").value(course.courseNo))
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/v1/courses/by-no/${course.courseNo}"))
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newCourse(): TestCourse {
|
||||||
|
val detail =
|
||||||
|
courseService.create(
|
||||||
|
CourseCreateRequest("Курс материалов ${UUID.randomUUID()}"),
|
||||||
|
UUID.randomUUID(),
|
||||||
|
)
|
||||||
|
return TestCourse(id = UUID.fromString(detail.id), courseNo = detail.courseNo)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seedChapter(
|
||||||
|
title: String,
|
||||||
|
courseId: UUID?,
|
||||||
|
audience: String?,
|
||||||
|
published: Boolean = true,
|
||||||
|
): ContentChapter =
|
||||||
|
chapters.saveAndFlush(
|
||||||
|
ContentChapter(
|
||||||
|
title = title,
|
||||||
|
slug = "chapter-${UUID.randomUUID()}",
|
||||||
|
position = 1,
|
||||||
|
published = published,
|
||||||
|
courseId = courseId,
|
||||||
|
audience = audience,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun seedMaterial(
|
||||||
|
courseId: UUID?,
|
||||||
|
published: Boolean,
|
||||||
|
): SeededMaterial {
|
||||||
|
val chapter = seedChapter(title = "Глава ${UUID.randomUUID()}", courseId = courseId, audience = null, published = published)
|
||||||
|
val section =
|
||||||
|
sections.saveAndFlush(
|
||||||
|
ContentSection(
|
||||||
|
chapterId = chapter.id,
|
||||||
|
title = "Раздел ${UUID.randomUUID()}",
|
||||||
|
slug = "section-${UUID.randomUUID()}",
|
||||||
|
position = 1,
|
||||||
|
published = published,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val article =
|
||||||
|
articles.saveAndFlush(
|
||||||
|
ContentArticle(
|
||||||
|
sectionId = section.id,
|
||||||
|
title = "Статья ${UUID.randomUUID()}",
|
||||||
|
slug = "article-${UUID.randomUUID()}",
|
||||||
|
bodyMd = "Текст",
|
||||||
|
position = 1,
|
||||||
|
published = published,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return SeededMaterial(chapter, section, article)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jwtFor(
|
||||||
|
subject: UUID,
|
||||||
|
role: String,
|
||||||
|
) = jwt().jwt { it.subject(subject.toString()).claim("role", role) }
|
||||||
|
|
||||||
|
private data class SeededMaterial(
|
||||||
|
val chapter: ContentChapter,
|
||||||
|
val section: ContentSection,
|
||||||
|
val article: ContentArticle,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class TestCourse(
|
||||||
|
val id: UUID,
|
||||||
|
val courseNo: Long,
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user