feat(academic): права преподавателя, группы курса и аудит

This commit is contained in:
2026-10-27 20:39:18 +03:00
parent 5affe9873b
commit 1de7207c3c
14 changed files with 1696 additions and 66 deletions

View File

@@ -0,0 +1,104 @@
package ru.ysu.lms.academic.audit
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.MediaType
import org.springframework.http.client.JdkClientHttpRequestFactory
import org.springframework.stereotype.Service
import org.springframework.web.client.RestClient
import java.net.http.HttpClient
import java.time.Duration
/**
* Клиент внутреннего endpoint аудита identity-service.
*
* Запись аудита — best-effort: недоступность identity не должна ломать бизнес-операцию,
* поэтому любые ошибки только логируются. Логин актора здесь не разрешается — identity
* уже знает пользователя по `actorAccountId`.
*/
@Service
class AuditClient internal constructor(
private val client: RestClient,
private val internalToken: String,
) {
@Autowired
constructor(
@Value("\${lms.identity.base-url:http://localhost:8081}") baseUrl: String,
@Value("\${lms.internal.token:dev-internal-token}") internalToken: String,
) : this(restClient(baseUrl), internalToken)
@Suppress("TooGenericExceptionCaught")
fun record(
actorAccountId: String?,
action: String,
entityType: String,
entityId: String?,
courseId: String? = null,
studentId: String? = null,
details: String = "",
) {
try {
client
.post()
.uri(AUDIT_PATH)
.header(INTERNAL_TOKEN_HEADER, internalToken)
.contentType(MediaType.APPLICATION_JSON)
.body(
AuditRequest(
actorAccountId = actorAccountId,
action = action,
entityType = entityType,
entityId = entityId,
courseId = courseId,
studentId = studentId,
details = details,
),
).retrieve()
.toBodilessEntity()
} catch (e: Exception) {
log.warn(
"Не удалось записать аудит: action={}, entityType={}, entityId={}",
action,
entityType,
entityId,
e,
)
}
}
private companion object {
private val log = LoggerFactory.getLogger(AuditClient::class.java)
private const val AUDIT_PATH = "/api/v1/internal/audit"
private const val INTERNAL_TOKEN_HEADER = "X-Internal-Token"
}
}
/** Тело `POST /api/v1/internal/audit`; поля null допустимы по контракту. */
internal data class AuditRequest(
val actorAccountId: String?,
val actorLogin: String? = null,
val action: String,
val entityType: String,
val entityId: String?,
val courseId: String?,
val studentId: String?,
val details: String,
)
/** Таймауты обязательны: зависший audit не должен надолго задерживать запрос. */
private const val CONNECT_TIMEOUT_SECONDS = 3L
private const val READ_TIMEOUT_SECONDS = 5L
private fun restClient(baseUrl: String): RestClient =
RestClient
.builder()
.baseUrl(baseUrl)
.requestFactory(
JdkClientHttpRequestFactory(
HttpClient
.newBuilder()
.connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS))
.build(),
).apply { setReadTimeout(Duration.ofSeconds(READ_TIMEOUT_SECONDS)) },
).build()

View File

@@ -9,6 +9,7 @@ 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
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ForbiddenException
import ru.ysu.lms.common.NotFoundException import ru.ysu.lms.common.NotFoundException
import java.util.UUID import java.util.UUID
@@ -32,8 +33,14 @@ class ContentAuthoringService(
articleId: UUID, articleId: UUID,
title: String, title: String,
bodyMd: String, bodyMd: String,
courseId: UUID? = null,
) { ) {
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") } val a =
if (courseId == null) {
articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
} else {
requireArticleInCourse(articleId, courseId)
}
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым") if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
a.title = title.trim() a.title = title.trim()
a.bodyMd = bodyMd a.bodyMd = bodyMd
@@ -41,28 +48,48 @@ class ContentAuthoringService(
} }
@Transactional @Transactional
fun deleteArticle(articleId: UUID) { fun deleteArticle(
if (!articles.existsById(articleId)) throw NotFoundException("Статья не найдена") articleId: UUID,
courseId: UUID? = null,
) {
if (courseId != null) {
requireArticleInCourse(articleId, courseId)
} else if (!articles.existsById(articleId)) {
throw NotFoundException("Статья не найдена")
}
articles.deleteById(articleId) articles.deleteById(articleId)
} }
/**
* Глобальная глава (`courseId == null`) получает аудиторию ALL и уникальный slug
* среди глобальных глав. Курсовая глава получает `courseId`, пустую аудиторию
* и уникальный slug внутри курса.
*/
@Transactional @Transactional
fun createChapter( fun createChapter(
title: String, title: String,
slug: String?, slug: String?,
courseId: UUID? = null,
): ChapterTreeNode { ): ChapterTreeNode {
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым") if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
val scope =
if (courseId == null) {
chapters.findByCourseIdIsNullOrderByPositionAscIdAsc()
} else {
chapters.findByCourseIdOrderByPositionAscIdAsc(courseId)
}
val cleanSlug = val cleanSlug =
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, scope.map { it.slug }.toSet())
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0 val maxPos = scope.maxOfOrNull { it.position } ?: 0
val ch = val ch =
chapters.save( chapters.save(
ContentChapter( ContentChapter(
title = title.trim(), title = title.trim(),
slug = cleanSlug, slug = cleanSlug,
position = maxPos + 1, position = maxPos + 1,
audience = AUDIENCE_ALL, audience = if (courseId == null) AUDIENCE_ALL else null,
courseId = courseId,
), ),
) )
return ChapterTreeNode( return ChapterTreeNode(
@@ -79,16 +106,24 @@ class ContentAuthoringService(
fun renameChapter( fun renameChapter(
chapterId: UUID, chapterId: UUID,
title: String, title: String,
courseId: UUID? = null,
) { ) {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") } val ch = findChapter(chapterId, courseId)
if (title.isBlank()) throw BadRequestException("Название не может быть пустым") if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
ch.title = title.trim() ch.title = title.trim()
chapters.save(ch) chapters.save(ch)
} }
@Transactional @Transactional
fun deleteChapter(chapterId: UUID) { fun deleteChapter(
if (!chapters.existsById(chapterId)) throw NotFoundException("Глава не найдена") chapterId: UUID,
courseId: UUID? = null,
) {
if (courseId != null) {
requireChapterInCourse(chapterId, courseId)
} else if (!chapters.existsById(chapterId)) {
throw NotFoundException("Глава не найдена")
}
chapters.deleteById(chapterId) chapters.deleteById(chapterId)
} }
@@ -97,8 +132,9 @@ class ContentAuthoringService(
chapterId: UUID, chapterId: UUID,
title: String, title: String,
slug: String?, slug: String?,
courseId: UUID? = null,
): SectionTreeNode { ): SectionTreeNode {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") } val ch = findChapter(chapterId, courseId)
if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым") if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым")
val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id) val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id)
val cleanSlug = val cleanSlug =
@@ -123,16 +159,24 @@ class ContentAuthoringService(
fun renameSection( fun renameSection(
sectionId: UUID, sectionId: UUID,
title: String, title: String,
courseId: UUID? = null,
) { ) {
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") } val sec = findSection(sectionId, courseId, "Раздел не найден")
if (title.isBlank()) throw BadRequestException("Название не может быть пустым") if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
sec.title = title.trim() sec.title = title.trim()
sections.save(sec) sections.save(sec)
} }
@Transactional @Transactional
fun deleteSection(sectionId: UUID) { fun deleteSection(
if (!sections.existsById(sectionId)) throw NotFoundException("Раздел не найден") sectionId: UUID,
courseId: UUID? = null,
) {
if (courseId != null) {
requireSectionInCourse(sectionId, courseId)
} else if (!sections.existsById(sectionId)) {
throw NotFoundException("Раздел не найден")
}
sections.deleteById(sectionId) sections.deleteById(sectionId)
} }
@@ -141,9 +185,10 @@ class ContentAuthoringService(
fun moveArticlesAndDeleteSection( fun moveArticlesAndDeleteSection(
sectionId: UUID, sectionId: UUID,
targetSectionId: UUID, targetSectionId: UUID,
courseId: UUID? = null,
) { ) {
val src = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") } val src = findSection(sectionId, courseId, "Раздел не найден")
val dst = sections.findById(targetSectionId).orElseThrow { NotFoundException("Раздел назначения не найден") } val dst = findSection(targetSectionId, courseId, "Раздел назначения не найден")
if (src.id == dst.id) throw BadRequestException("Раздел назначения совпадает с исходным") if (src.id == dst.id) throw BadRequestException("Раздел назначения совпадает с исходным")
val moving = articles.findBySectionIdOrderByPositionAscIdAsc(src.id) val moving = articles.findBySectionIdOrderByPositionAscIdAsc(src.id)
var pos = articles.findBySectionIdOrderByPositionAscIdAsc(dst.id).maxOfOrNull { it.position } ?: 0 var pos = articles.findBySectionIdOrderByPositionAscIdAsc(dst.id).maxOfOrNull { it.position } ?: 0
@@ -156,41 +201,39 @@ class ContentAuthoringService(
sections.delete(src) sections.delete(src)
} }
/** Создаёт статью; если раздела нет — создаёт главу/раздел по названиям. */ /**
* Создаёт статью; если раздела нет — создаёт главу/раздел по названиям.
* При `courseId != null` все вложенные сущности обязаны принадлежать этому курсу,
* а новые главы и разделы создаются в нём.
*/
@Transactional @Transactional
fun createArticlePlaced(placement: ArticlePlacement): ContentArticleView { fun createArticlePlaced(
placement: ArticlePlacement,
courseId: UUID? = null,
): ContentArticleView {
val targetSection: ContentSection = val targetSection: ContentSection =
when { when {
placement.sectionId != null && !placement.newSectionTitle.isNullOrBlank() -> { placement.sectionId != null && !placement.newSectionTitle.isNullOrBlank() -> {
val anchor = val anchor = findSection(placement.sectionId, courseId, "Раздел не найден")
sections val node = createSection(anchor.chapterId, placement.newSectionTitle, null, courseId)
.findById(placement.sectionId)
.orElseThrow { NotFoundException("Раздел не найден") }
val node = createSection(anchor.chapterId, placement.newSectionTitle, null)
sections sections
.findById(UUID.fromString(node.id)) .findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Раздел не найден") } .orElseThrow { NotFoundException("Раздел не найден") }
} }
placement.sectionId != null -> placement.sectionId != null -> findSection(placement.sectionId, courseId, "Раздел не найден")
sections
.findById(placement.sectionId)
.orElseThrow { NotFoundException("Раздел не найден") }
!placement.newSectionTitle.isNullOrBlank() -> { !placement.newSectionTitle.isNullOrBlank() -> {
val chapter: ContentChapter = val chapter: ContentChapter =
when { when {
placement.chapterId != null -> placement.chapterId != null -> findChapter(placement.chapterId, courseId)
chapters
.findById(placement.chapterId)
.orElseThrow { NotFoundException("Глава не найдена") }
!placement.newChapterTitle.isNullOrBlank() -> { !placement.newChapterTitle.isNullOrBlank() -> {
val node = createChapter(placement.newChapterTitle, null) val node = createChapter(placement.newChapterTitle, null, courseId)
chapters chapters
.findById(UUID.fromString(node.id)) .findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Глава не найдена") } .orElseThrow { NotFoundException("Глава не найдена") }
} }
else -> throw BadRequestException("Укажите главу") else -> throw BadRequestException("Укажите главу")
} }
val node = createSection(chapter.id, placement.newSectionTitle, null) val node = createSection(chapter.id, placement.newSectionTitle, null, courseId)
sections sections
.findById(UUID.fromString(node.id)) .findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Раздел не найден") } .orElseThrow { NotFoundException("Раздел не найден") }
@@ -251,4 +294,55 @@ class ContentAuthoringService(
bodyMd = a.bodyMd, bodyMd = a.bodyMd,
) )
} }
/**
* Проверяет, что глава существует и принадлежит курсу. Чужой курс — 403,
* отсутствующая глава — 404, чтобы не путать ошибку доступа с опечаткой в id.
*/
fun requireChapterInCourse(
chapterId: UUID,
courseId: UUID,
): ContentChapter {
val chapter = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
if (chapter.courseId != courseId) throw ForbiddenException("Глава принадлежит другому курсу")
return chapter
}
fun requireSectionInCourse(
sectionId: UUID,
courseId: UUID,
): ContentSection {
val section = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
requireChapterInCourse(section.chapterId, courseId)
return section
}
fun requireArticleInCourse(
articleId: UUID,
courseId: UUID,
): ContentArticle {
val article = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
requireSectionInCourse(article.sectionId, courseId)
return article
}
private fun findChapter(
chapterId: UUID,
courseId: UUID?,
): ContentChapter =
if (courseId == null) {
chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
} else {
requireChapterInCourse(chapterId, courseId)
}
private fun findSection(
sectionId: UUID,
courseId: UUID?,
notFoundMessage: String,
): ContentSection {
val section = sections.findById(sectionId).orElseThrow { NotFoundException(notFoundMessage) }
if (courseId != null) requireChapterInCourse(section.chapterId, courseId)
return section
}
} }

View File

@@ -2,6 +2,8 @@ package ru.ysu.lms.academic.controller
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize 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.DeleteMapping import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
@@ -12,7 +14,9 @@ import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.academic.audit.AuditClient
import ru.ysu.lms.academic.content.ArticlePlacement import ru.ysu.lms.academic.content.ArticlePlacement
import ru.ysu.lms.academic.content.ContentArticleView
import ru.ysu.lms.academic.content.ContentAuthoringService import ru.ysu.lms.academic.content.ContentAuthoringService
import ru.ysu.lms.academic.content.ContentImportResult import ru.ysu.lms.academic.content.ContentImportResult
import ru.ysu.lms.academic.content.ContentService import ru.ysu.lms.academic.content.ContentService
@@ -25,6 +29,7 @@ import java.util.UUID
class ContentAdminController( class ContentAdminController(
private val contentService: ContentService, private val contentService: ContentService,
private val authoring: ContentAuthoringService, private val authoring: ContentAuthoringService,
private val audit: AuditClient,
) { ) {
data class ArticleUpdatePayload( data class ArticleUpdatePayload(
val title: String, val title: String,
@@ -81,33 +86,66 @@ class ContentAdminController(
fun chapterPublished( fun chapterPublished(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: PublishedPayload, @RequestBody body: PublishedPayload,
@AuthenticationPrincipal jwt: Jwt,
) { ) {
contentService.setChapterPublished(id, body.published) contentService.setChapterPublished(id, body.published)
audit.record(
jwt.subject,
"CONTENT_CHAPTER_PUBLISH",
ENTITY_CHAPTER,
id.toString(),
details = "published=${body.published}",
)
} }
@PutMapping("/sections/{id}/published") @PutMapping("/sections/{id}/published")
fun sectionPublished( fun sectionPublished(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: PublishedPayload, @RequestBody body: PublishedPayload,
@AuthenticationPrincipal jwt: Jwt,
) { ) {
contentService.setSectionPublished(id, body.published) contentService.setSectionPublished(id, body.published)
audit.record(
jwt.subject,
"CONTENT_SECTION_PUBLISH",
ENTITY_SECTION,
id.toString(),
details = "published=${body.published}",
)
} }
@PutMapping("/articles/{id}/published") @PutMapping("/articles/{id}/published")
fun articlePublished( fun articlePublished(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: PublishedPayload, @RequestBody body: PublishedPayload,
@AuthenticationPrincipal jwt: Jwt,
) { ) {
contentService.setArticlePublished(id, body.published) contentService.setArticlePublished(id, body.published)
audit.record(
jwt.subject,
"CONTENT_ARTICLE_PUBLISH",
ENTITY_ARTICLE,
id.toString(),
details = "published=${body.published}",
)
} }
@PostMapping("/import") @PostMapping("/import")
fun import( fun import(
@RequestParam("file") file: MultipartFile, @RequestParam("file") file: MultipartFile,
@RequestParam(value = "sync", defaultValue = "true") sync: Boolean, @RequestParam(value = "sync", defaultValue = "true") sync: Boolean,
@AuthenticationPrincipal jwt: Jwt,
): ContentImportResult { ): ContentImportResult {
if (file.isEmpty) throw BadRequestException("Выберите файл") if (file.isEmpty) throw BadRequestException("Выберите файл")
return contentService.importZip(file.originalFilename ?: "content.zip", file.bytes, sync) val result = contentService.importZip(file.originalFilename ?: "content.zip", file.bytes, sync)
audit.record(
jwt.subject,
"CONTENT_IMPORT",
ENTITY_CONTENT,
null,
details = "chapters=${result.chapters}, sections=${result.sections}, articles=${result.articles}",
)
return result
} }
@GetMapping("/articles") @GetMapping("/articles")
@@ -116,19 +154,34 @@ class ContentAdminController(
@PostMapping("/chapters") @PostMapping("/chapters")
fun createChapter( fun createChapter(
@RequestBody body: ChapterCreatePayload, @RequestBody body: ChapterCreatePayload,
) = authoring.createChapter(body.title, body.slug) @AuthenticationPrincipal jwt: Jwt,
) = authoring.createChapter(body.title, body.slug).also {
audit.record(
jwt.subject,
"CONTENT_CHAPTER_CREATE",
ENTITY_CHAPTER,
it.id,
details = "title=${body.title}",
)
}
@PutMapping("/chapters/{id}") @PutMapping("/chapters/{id}")
fun renameChapter( fun renameChapter(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: TitlePayload, @RequestBody body: TitlePayload,
) = authoring.renameChapter(id, body.title) @AuthenticationPrincipal jwt: Jwt,
) {
authoring.renameChapter(id, body.title)
audit.record(jwt.subject, "CONTENT_CHAPTER_RENAME", ENTITY_CHAPTER, id.toString(), details = "title=${body.title}")
}
@DeleteMapping("/chapters/{id}") @DeleteMapping("/chapters/{id}")
fun deleteChapter( fun deleteChapter(
@PathVariable id: UUID, @PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> { ): ResponseEntity<Void> {
authoring.deleteChapter(id) authoring.deleteChapter(id)
audit.record(jwt.subject, "CONTENT_CHAPTER_DELETE", ENTITY_CHAPTER, id.toString())
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
@@ -136,31 +189,49 @@ class ContentAdminController(
fun createSection( fun createSection(
@RequestParam chapterId: UUID, @RequestParam chapterId: UUID,
@RequestBody body: SectionCreatePayload, @RequestBody body: SectionCreatePayload,
) = authoring.createSection(chapterId, body.title, body.slug) @AuthenticationPrincipal jwt: Jwt,
) = authoring.createSection(chapterId, body.title, body.slug).also {
audit.record(
jwt.subject,
"CONTENT_SECTION_CREATE",
ENTITY_SECTION,
it.id,
details = "title=${body.title}",
)
}
@PutMapping("/sections/{id}") @PutMapping("/sections/{id}")
fun renameSection( fun renameSection(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: TitlePayload, @RequestBody body: TitlePayload,
) = authoring.renameSection(id, body.title) @AuthenticationPrincipal jwt: Jwt,
) {
authoring.renameSection(id, body.title)
audit.record(jwt.subject, "CONTENT_SECTION_RENAME", ENTITY_SECTION, id.toString(), details = "title=${body.title}")
}
@DeleteMapping("/sections/{id}") @DeleteMapping("/sections/{id}")
fun deleteSection( fun deleteSection(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestParam(required = false) moveTo: UUID?, @RequestParam(required = false) moveTo: UUID?,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> { ): ResponseEntity<Void> {
if (moveTo != null) { if (moveTo != null) {
authoring.moveArticlesAndDeleteSection(id, moveTo) authoring.moveArticlesAndDeleteSection(id, moveTo)
} else { } else {
authoring.deleteSection(id) authoring.deleteSection(id)
} }
audit.record(jwt.subject, "CONTENT_SECTION_DELETE", ENTITY_SECTION, id.toString())
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
@PostMapping("/articles") @PostMapping("/articles")
fun createArticle( fun createArticle(
@RequestBody body: ArticleCreatePayload, @RequestBody body: ArticleCreatePayload,
) = authoring.createArticlePlaced( @AuthenticationPrincipal jwt: Jwt,
): ContentArticleView {
val article =
authoring.createArticlePlaced(
ArticlePlacement( ArticlePlacement(
title = body.title, title = body.title,
bodyMd = body.bodyMd, bodyMd = body.bodyMd,
@@ -170,26 +241,55 @@ class ContentAdminController(
newSectionTitle = body.newSectionTitle, newSectionTitle = body.newSectionTitle,
), ),
) )
audit.record(
jwt.subject,
"CONTENT_ARTICLE_CREATE",
ENTITY_ARTICLE,
article.id,
details = "title=${body.title}",
)
return article
}
@PutMapping("/articles/{id}") @PutMapping("/articles/{id}")
fun update( fun update(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: ArticleUpdatePayload, @RequestBody body: ArticleUpdatePayload,
@AuthenticationPrincipal jwt: Jwt,
) { ) {
authoring.updateArticle(id, body.title, body.bodyMd) authoring.updateArticle(id, body.title, body.bodyMd)
audit.record(jwt.subject, "CONTENT_ARTICLE_UPDATE", ENTITY_ARTICLE, id.toString(), details = "title=${body.title}")
} }
@PostMapping("/articles/{id}/move") @PostMapping("/articles/{id}/move")
fun move( fun move(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: ArticleMovePayload, @RequestBody body: ArticleMovePayload,
) = authoring.moveArticle(id, UUID.fromString(body.sectionId)) @AuthenticationPrincipal jwt: Jwt,
) = authoring.moveArticle(id, UUID.fromString(body.sectionId)).also {
audit.record(
jwt.subject,
"CONTENT_ARTICLE_MOVE",
ENTITY_ARTICLE,
it.id,
details = "sectionId=${body.sectionId}",
)
}
@DeleteMapping("/articles/{id}") @DeleteMapping("/articles/{id}")
fun delete( fun delete(
@PathVariable id: UUID, @PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> { ): ResponseEntity<Void> {
authoring.deleteArticle(id) authoring.deleteArticle(id)
audit.record(jwt.subject, "CONTENT_ARTICLE_DELETE", ENTITY_ARTICLE, id.toString())
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
private companion object {
private const val ENTITY_CONTENT = "CONTENT"
private const val ENTITY_CHAPTER = "CONTENT_CHAPTER"
private const val ENTITY_SECTION = "CONTENT_SECTION"
private const val ENTITY_ARTICLE = "CONTENT_ARTICLE"
}
} }

View File

@@ -0,0 +1,316 @@
package ru.ysu.lms.academic.controller
import org.springframework.http.ResponseEntity
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.DeleteMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.academic.audit.AuditClient
import ru.ysu.lms.academic.content.ArticlePlacement
import ru.ysu.lms.academic.content.ContentArticleView
import ru.ysu.lms.academic.content.ContentAuthoringService
import ru.ysu.lms.academic.content.ContentService
import ru.ysu.lms.academic.course.CourseAccessService
import ru.ysu.lms.academic.repo.CourseRepository
import ru.ysu.lms.common.NotFoundException
import java.util.UUID
/**
* Авторский контент курса: администратор и преподаватель, назначенный на курс.
* Любая изменяемая сущность обязана принадлежать курсу из пути — чужие id
* отклоняются, чтобы нельзя было править материалы другого курса.
*/
@RestController
@RequestMapping("/api/v1/courses/{courseId}/content")
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
class CourseContentAdminController(
private val contentService: ContentService,
private val authoring: ContentAuthoringService,
private val accessService: CourseAccessService,
private val courses: CourseRepository,
private val audit: AuditClient,
) {
data class ChapterCreatePayload(
val title: String,
val slug: String? = null,
)
data class SectionCreatePayload(
val title: String,
val slug: String? = null,
)
data class TitlePayload(
val title: String,
)
data class ArticleCreatePayload(
val chapterId: String? = null,
val sectionId: String? = null,
val newChapterTitle: String? = null,
val newSectionTitle: String? = null,
val title: String,
val bodyMd: String? = null,
)
data class ArticleUpdatePayload(
val title: String,
val bodyMd: String,
)
data class PublishedPayload(
val published: Boolean,
)
@PostMapping("/chapters")
fun createChapter(
@PathVariable courseId: UUID,
@RequestBody body: ChapterCreatePayload,
@AuthenticationPrincipal jwt: Jwt,
) = authoring.createChapter(body.title, body.slug, requireAccess(courseId, jwt)).also {
audit.record(
jwt.subject,
"CONTENT_CHAPTER_CREATE",
ENTITY_CHAPTER,
it.id,
courseId = courseId.toString(),
details = "title=${body.title}",
)
}
@PutMapping("/chapters/{id}")
fun renameChapter(
@PathVariable courseId: UUID,
@PathVariable id: UUID,
@RequestBody body: TitlePayload,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
requireAccess(courseId, jwt)
authoring.renameChapter(id, body.title, courseId)
audit.record(
jwt.subject,
"CONTENT_CHAPTER_RENAME",
ENTITY_CHAPTER,
id.toString(),
courseId = courseId.toString(),
details = "title=${body.title}",
)
return ResponseEntity.noContent().build()
}
@DeleteMapping("/chapters/{id}")
fun deleteChapter(
@PathVariable courseId: UUID,
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
requireAccess(courseId, jwt)
authoring.deleteChapter(id, courseId)
audit.record(jwt.subject, "CONTENT_CHAPTER_DELETE", ENTITY_CHAPTER, id.toString(), courseId = courseId.toString())
return ResponseEntity.noContent().build()
}
@PostMapping("/sections")
fun createSection(
@PathVariable courseId: UUID,
@RequestParam chapterId: UUID,
@RequestBody body: SectionCreatePayload,
@AuthenticationPrincipal jwt: Jwt,
) = authoring.createSection(chapterId, body.title, body.slug, requireAccess(courseId, jwt)).also {
audit.record(
jwt.subject,
"CONTENT_SECTION_CREATE",
ENTITY_SECTION,
it.id,
courseId = courseId.toString(),
details = "title=${body.title}",
)
}
@PutMapping("/sections/{id}")
fun renameSection(
@PathVariable courseId: UUID,
@PathVariable id: UUID,
@RequestBody body: TitlePayload,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
requireAccess(courseId, jwt)
authoring.renameSection(id, body.title, courseId)
audit.record(
jwt.subject,
"CONTENT_SECTION_RENAME",
ENTITY_SECTION,
id.toString(),
courseId = courseId.toString(),
details = "title=${body.title}",
)
return ResponseEntity.noContent().build()
}
@DeleteMapping("/sections/{id}")
fun deleteSection(
@PathVariable courseId: UUID,
@PathVariable id: UUID,
@RequestParam(required = false) moveTo: UUID?,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
requireAccess(courseId, jwt)
if (moveTo != null) {
authoring.moveArticlesAndDeleteSection(id, moveTo, courseId)
} else {
authoring.deleteSection(id, courseId)
}
audit.record(jwt.subject, "CONTENT_SECTION_DELETE", ENTITY_SECTION, id.toString(), courseId = courseId.toString())
return ResponseEntity.noContent().build()
}
@PostMapping("/articles")
fun createArticle(
@PathVariable courseId: UUID,
@RequestBody body: ArticleCreatePayload,
@AuthenticationPrincipal jwt: Jwt,
): ContentArticleView {
val article =
authoring.createArticlePlaced(
ArticlePlacement(
title = body.title,
bodyMd = body.bodyMd ?: "",
chapterId = body.chapterId?.let { UUID.fromString(it) },
newChapterTitle = body.newChapterTitle,
sectionId = body.sectionId?.let { UUID.fromString(it) },
newSectionTitle = body.newSectionTitle,
),
courseId = requireAccess(courseId, jwt),
)
audit.record(
jwt.subject,
"CONTENT_ARTICLE_CREATE",
ENTITY_ARTICLE,
article.id,
courseId = courseId.toString(),
details = "title=${body.title}",
)
return article
}
@PutMapping("/articles/{id}")
fun updateArticle(
@PathVariable courseId: UUID,
@PathVariable id: UUID,
@RequestBody body: ArticleUpdatePayload,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
requireAccess(courseId, jwt)
authoring.updateArticle(id, body.title, body.bodyMd, courseId)
audit.record(
jwt.subject,
"CONTENT_ARTICLE_UPDATE",
ENTITY_ARTICLE,
id.toString(),
courseId = courseId.toString(),
details = "title=${body.title}",
)
return ResponseEntity.noContent().build()
}
@DeleteMapping("/articles/{id}")
fun deleteArticle(
@PathVariable courseId: UUID,
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
requireAccess(courseId, jwt)
authoring.deleteArticle(id, courseId)
audit.record(jwt.subject, "CONTENT_ARTICLE_DELETE", ENTITY_ARTICLE, id.toString(), courseId = courseId.toString())
return ResponseEntity.noContent().build()
}
@PutMapping("/chapters/{id}/published")
fun chapterPublished(
@PathVariable courseId: UUID,
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
requireAccess(courseId, jwt)
authoring.requireChapterInCourse(id, courseId)
contentService.setChapterPublished(id, body.published)
audit.record(
jwt.subject,
"CONTENT_CHAPTER_PUBLISH",
ENTITY_CHAPTER,
id.toString(),
courseId = courseId.toString(),
details = "published=${body.published}",
)
return ResponseEntity.noContent().build()
}
@PutMapping("/sections/{id}/published")
fun sectionPublished(
@PathVariable courseId: UUID,
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
requireAccess(courseId, jwt)
authoring.requireSectionInCourse(id, courseId)
contentService.setSectionPublished(id, body.published)
audit.record(
jwt.subject,
"CONTENT_SECTION_PUBLISH",
ENTITY_SECTION,
id.toString(),
courseId = courseId.toString(),
details = "published=${body.published}",
)
return ResponseEntity.noContent().build()
}
@PutMapping("/articles/{id}/published")
fun articlePublished(
@PathVariable courseId: UUID,
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
requireAccess(courseId, jwt)
authoring.requireArticleInCourse(id, courseId)
contentService.setArticlePublished(id, body.published)
audit.record(
jwt.subject,
"CONTENT_ARTICLE_PUBLISH",
ENTITY_ARTICLE,
id.toString(),
courseId = courseId.toString(),
details = "published=${body.published}",
)
return ResponseEntity.noContent().build()
}
/**
* Проверяет курсовую область видимости и существование курса (для администратора,
* который проходит проверку доступа без запроса к БД). Возвращает id курса.
*/
private fun requireAccess(
courseId: UUID,
jwt: Jwt,
): UUID {
accessService.requireCourseAccess(courseId, jwt)
if (!courses.existsById(courseId)) throw NotFoundException("Курс не найден")
return courseId
}
private companion object {
private const val ENTITY_CHAPTER = "CONTENT_CHAPTER"
private const val ENTITY_SECTION = "CONTENT_SECTION"
private const val ENTITY_ARTICLE = "CONTENT_ARTICLE"
}
}

View File

@@ -11,14 +11,17 @@ import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
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.audit.AuditClient
import ru.ysu.lms.academic.course.CourseAccessService import ru.ysu.lms.academic.course.CourseAccessService
import ru.ysu.lms.academic.course.CourseBootstrapRequest import ru.ysu.lms.academic.course.CourseBootstrapRequest
import ru.ysu.lms.academic.course.CourseBootstrapResult import ru.ysu.lms.academic.course.CourseBootstrapResult
import ru.ysu.lms.academic.course.CourseBootstrapService import ru.ysu.lms.academic.course.CourseBootstrapService
import ru.ysu.lms.academic.course.CourseCreateRequest import ru.ysu.lms.academic.course.CourseCreateRequest
import ru.ysu.lms.academic.course.CourseDetail import ru.ysu.lms.academic.course.CourseDetail
import ru.ysu.lms.academic.course.CourseGroupOption
import ru.ysu.lms.academic.course.CourseGroupRequest import ru.ysu.lms.academic.course.CourseGroupRequest
import ru.ysu.lms.academic.course.CourseGroupRoster import ru.ysu.lms.academic.course.CourseGroupRoster
import ru.ysu.lms.academic.course.CourseGroupService
import ru.ysu.lms.academic.course.CourseRosterService import ru.ysu.lms.academic.course.CourseRosterService
import ru.ysu.lms.academic.course.CourseService import ru.ysu.lms.academic.course.CourseService
import ru.ysu.lms.academic.course.CourseSummary import ru.ysu.lms.academic.course.CourseSummary
@@ -35,6 +38,8 @@ class CourseController(
private val bootstrapService: CourseBootstrapService, private val bootstrapService: CourseBootstrapService,
private val accessService: CourseAccessService, private val accessService: CourseAccessService,
private val rosterService: CourseRosterService, private val rosterService: CourseRosterService,
private val courseGroupService: CourseGroupService,
private val audit: AuditClient,
) { ) {
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
@@ -51,7 +56,11 @@ class CourseController(
fun create( fun create(
@RequestBody body: CourseCreateRequest, @RequestBody body: CourseCreateRequest,
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): CourseDetail = courseService.create(body, ownerAccountId(jwt)) ): CourseDetail {
val course = courseService.create(body, ownerAccountId(jwt))
audit.record(jwt.subject, "COURSE_CREATE", ENTITY_COURSE, course.id, courseId = course.id)
return course
}
@GetMapping("/{id}") @GetMapping("/{id}")
fun detail( fun detail(
@@ -87,42 +96,131 @@ class CourseController(
fun update( fun update(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: CourseUpdateRequest, @RequestBody body: CourseUpdateRequest,
): CourseDetail = courseService.update(id, body) @AuthenticationPrincipal jwt: Jwt,
): CourseDetail {
val course = courseService.update(id, body)
val details =
listOfNotNull(
body.title?.let { "title=$it" },
body.status?.let { "status=$it" },
).joinToString(", ")
audit.record(jwt.subject, "COURSE_UPDATE", ENTITY_COURSE, course.id, courseId = course.id, details = details)
return course
}
@PostMapping("/{id}/teachers") @PostMapping("/{id}/teachers")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun assignTeacher( fun assignTeacher(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: CourseTeacherRequest, @RequestBody body: CourseTeacherRequest,
): CourseDetail = courseService.assignTeacher(id, body) @AuthenticationPrincipal jwt: Jwt,
): CourseDetail {
val course = courseService.assignTeacher(id, body)
audit.record(
jwt.subject,
"COURSE_TEACHER_ASSIGN",
ENTITY_COURSE,
course.id,
courseId = course.id,
details = "accountId=${body.accountId}",
)
return course
}
@DeleteMapping("/{id}/teachers/{accountId}") @DeleteMapping("/{id}/teachers/{accountId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun removeTeacher( fun removeTeacher(
@PathVariable id: UUID, @PathVariable id: UUID,
@PathVariable accountId: String, @PathVariable accountId: String,
): CourseDetail = courseService.removeTeacher(id, accountId) @AuthenticationPrincipal jwt: Jwt,
): CourseDetail {
val course = courseService.removeTeacher(id, accountId)
audit.record(
jwt.subject,
"COURSE_TEACHER_REMOVE",
ENTITY_COURSE,
course.id,
courseId = course.id,
details = "accountId=$accountId",
)
return course
}
@PostMapping("/{id}/groups") @PostMapping("/{id}/groups")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun attachGroup( fun attachGroup(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: CourseGroupRequest, @RequestBody body: CourseGroupRequest,
): CourseDetail = courseService.attachGroup(id, body) @AuthenticationPrincipal jwt: Jwt,
): CourseDetail {
accessService.requireCourseAccess(id, jwt)
courseGroupService.requireAttachable(body.groupId)
val course = courseService.attachGroup(id, body)
audit.record(
jwt.subject,
"COURSE_GROUP_ATTACH",
ENTITY_COURSE,
course.id,
courseId = course.id,
details = "groupId=${body.groupId}",
)
return course
}
@DeleteMapping("/{id}/groups/{groupId}") @DeleteMapping("/{id}/groups/{groupId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun detachGroup( fun detachGroup(
@PathVariable id: UUID, @PathVariable id: UUID,
@PathVariable groupId: String, @PathVariable groupId: String,
): CourseDetail = courseService.detachGroup(id, groupId) @AuthenticationPrincipal jwt: Jwt,
): CourseDetail {
accessService.requireCourseAccess(id, jwt)
val course = courseService.detachGroup(id, groupId)
audit.record(
jwt.subject,
"COURSE_GROUP_DETACH",
ENTITY_COURSE,
course.id,
courseId = course.id,
details = "groupId=$groupId",
)
return course
}
/** Активные группы identity, ещё не привязанные к курсу. */
@GetMapping("/{id}/available-groups")
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun availableGroups(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): List<CourseGroupOption> {
accessService.requireCourseAccess(id, jwt)
return courseGroupService.availableGroups(id)
}
@PostMapping("/bootstrap") @PostMapping("/bootstrap")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun bootstrap( fun bootstrap(
@RequestBody body: CourseBootstrapRequest, @RequestBody body: CourseBootstrapRequest,
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): CourseBootstrapResult = bootstrapService.bootstrap(body, ownerAccountId(jwt)) ): CourseBootstrapResult {
val result = bootstrapService.bootstrap(body, ownerAccountId(jwt))
audit.record(
jwt.subject,
"COURSE_BOOTSTRAP",
ENTITY_COURSE,
result.courseId,
courseId = result.courseId,
details =
"groups=${result.attachedGroups}, labs=${result.assignedLabs}, " +
"chapters=${result.assignedChapters}",
)
return result
}
private fun ownerAccountId(jwt: Jwt): UUID = parseUuidOrBadRequest(jwt.subject ?: "", "идентификатор пользователя в токене") private fun ownerAccountId(jwt: Jwt): UUID = parseUuidOrBadRequest(jwt.subject ?: "", "идентификатор пользователя в токене")
private companion object {
private const val ENTITY_COURSE = "COURSE"
}
} }

View File

@@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.academic.audit.AuditClient
import ru.ysu.lms.academic.course.CourseAccessService import ru.ysu.lms.academic.course.CourseAccessService
import ru.ysu.lms.academic.lab.LabCopyRequest import ru.ysu.lms.academic.lab.LabCopyRequest
import ru.ysu.lms.academic.lab.LabCreateRequest import ru.ysu.lms.academic.lab.LabCreateRequest
@@ -34,6 +35,7 @@ import java.util.UUID
class LabController( class LabController(
private val labService: LabService, private val labService: LabService,
private val accessService: CourseAccessService, private val accessService: CourseAccessService,
private val audit: AuditClient,
) { ) {
/** /**
* Без `courseId` список ограничен доступными курсами (admin видит все). * Без `courseId` список ограничен доступными курсами (admin видит все).
@@ -71,7 +73,16 @@ class LabController(
): LabDetail { ): LabDetail {
accessService.requireLabAccess(id, jwt) accessService.requireLabAccess(id, jwt)
accessService.requireCourseAccess(body.courseId, jwt) accessService.requireCourseAccess(body.courseId, jwt)
return labService.copy(id, body) val copy = labService.copy(id, body)
audit.record(
jwt.subject,
"LAB_COPY",
ENTITY_LAB,
copy.id,
courseId = copy.courseId,
details = "sourceLabId=$id",
)
return copy
} }
@PostMapping("/import") @PostMapping("/import")
@@ -83,7 +94,16 @@ class LabController(
): LabImportResult { ): LabImportResult {
if (file.isEmpty) throw BadRequestException("Выберите файл") if (file.isEmpty) throw BadRequestException("Выберите файл")
val targetCourseId = requireWritableCourse(courseId, jwt) val targetCourseId = requireWritableCourse(courseId, jwt)
return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes, targetCourseId) val result = labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes, targetCourseId)
audit.record(
jwt.subject,
"LAB_IMPORT",
ENTITY_LAB,
result.labId,
courseId = targetCourseId?.toString(),
details = "levels=${result.levelCount}, variants=${result.variantCount}",
)
return result
} }
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@@ -94,6 +114,7 @@ class LabController(
) { ) {
accessService.requireLabAccess(id, jwt) accessService.requireLabAccess(id, jwt)
labService.delete(id) labService.delete(id)
audit.record(jwt.subject, "LAB_DELETE", ENTITY_LAB, id.toString())
} }
@PutMapping("/{id}") @PutMapping("/{id}")
@@ -104,7 +125,16 @@ class LabController(
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): LabDetail { ): LabDetail {
accessService.requireLabAccess(id, jwt) accessService.requireLabAccess(id, jwt)
return labService.update(id, body) val lab = labService.update(id, body)
audit.record(
jwt.subject,
"LAB_UPDATE",
ENTITY_LAB,
lab.id,
courseId = lab.courseId,
details = "status=${lab.status}",
)
return lab
} }
@PostMapping("/{id}/variants") @PostMapping("/{id}/variants")
@@ -115,7 +145,9 @@ class LabController(
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): LabDetail { ): LabDetail {
accessService.requireLabAccess(id, jwt) accessService.requireLabAccess(id, jwt)
return labService.addVariant(id, body) val lab = labService.addVariant(id, body)
audit.record(jwt.subject, "LAB_VARIANT_ADD", ENTITY_LAB, id.toString(), details = "number=${body.number}")
return lab
} }
@PutMapping("/{id}/variants/{variantId}") @PutMapping("/{id}/variants/{variantId}")
@@ -127,7 +159,15 @@ class LabController(
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): LabDetail { ): LabDetail {
accessService.requireLabAccess(id, jwt) accessService.requireLabAccess(id, jwt)
return labService.updateVariant(id, variantId, body) val lab = labService.updateVariant(id, variantId, body)
audit.record(
jwt.subject,
"LAB_VARIANT_UPDATE",
ENTITY_LAB,
id.toString(),
details = "variantId=$variantId, number=${body.number}",
)
return lab
} }
@DeleteMapping("/{id}/variants/{variantId}") @DeleteMapping("/{id}/variants/{variantId}")
@@ -138,7 +178,9 @@ class LabController(
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): LabDetail { ): LabDetail {
accessService.requireLabAccess(id, jwt) accessService.requireLabAccess(id, jwt)
return labService.deleteVariant(id, variantId) val lab = labService.deleteVariant(id, variantId)
audit.record(jwt.subject, "LAB_VARIANT_DELETE", ENTITY_LAB, id.toString(), details = "variantId=$variantId")
return lab
} }
@PostMapping("/{id}/toggle-status") @PostMapping("/{id}/toggle-status")
@@ -149,6 +191,7 @@ class LabController(
) { ) {
accessService.requireLabAccess(id, jwt) accessService.requireLabAccess(id, jwt)
labService.toggleStatus(id) labService.toggleStatus(id)
audit.record(jwt.subject, "LAB_PUBLISH", ENTITY_LAB, id.toString())
} }
@PostMapping @PostMapping
@@ -158,7 +201,16 @@ class LabController(
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): LabDetail { ): LabDetail {
requireWritableCourse(body.courseId, jwt) requireWritableCourse(body.courseId, jwt)
return labService.create(body) val lab = labService.create(body)
audit.record(
jwt.subject,
"LAB_CREATE",
ENTITY_LAB,
lab.id,
courseId = lab.courseId,
details = "title=${lab.title}",
)
return lab
} }
@PostMapping("/{id}/levels") @PostMapping("/{id}/levels")
@@ -170,6 +222,7 @@ class LabController(
): LabDetail { ): LabDetail {
accessService.requireLabAccess(id, jwt) accessService.requireLabAccess(id, jwt)
labService.addLevel(id, body) labService.addLevel(id, body)
audit.record(jwt.subject, "LAB_LEVEL_ADD", ENTITY_LAB, id.toString(), details = "code=${body.code}")
return labService.detail(id) return labService.detail(id)
} }
@@ -182,7 +235,9 @@ class LabController(
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): LabDetail { ): LabDetail {
accessService.requireLabAccess(id, jwt) accessService.requireLabAccess(id, jwt)
return labService.renameLevel(id, levelId, body) val lab = labService.renameLevel(id, levelId, body)
audit.record(jwt.subject, "LAB_LEVEL_UPDATE", ENTITY_LAB, id.toString(), details = "levelId=$levelId")
return lab
} }
@DeleteMapping("/{id}/levels/{levelId}") @DeleteMapping("/{id}/levels/{levelId}")
@@ -193,7 +248,9 @@ class LabController(
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): LabDetail { ): LabDetail {
accessService.requireLabAccess(id, jwt) accessService.requireLabAccess(id, jwt)
return labService.deleteLevel(id, levelId) val lab = labService.deleteLevel(id, levelId)
audit.record(jwt.subject, "LAB_LEVEL_DELETE", ENTITY_LAB, id.toString(), details = "levelId=$levelId")
return lab
} }
/** /**
@@ -211,4 +268,8 @@ class LabController(
if (!accessService.isAdmin(jwt)) throw BadRequestException("Укажите курс") if (!accessService.isAdmin(jwt)) throw BadRequestException("Укажите курс")
return null return null
} }
private companion object {
private const val ENTITY_LAB = "LAB"
}
} }

View File

@@ -52,6 +52,13 @@ data class CourseGroupRequest(
val groupId: String, val groupId: String,
) )
/** Группа, которую можно привязать к курсу: ещё не привязана и активна в identity. */
data class CourseGroupOption(
val id: String,
val name: String,
val status: String,
)
data class CourseBootstrapRequest( data class CourseBootstrapRequest(
val groupIds: List<String> = emptyList(), val groupIds: List<String> = emptyList(),
val title: String? = null, val title: String? = null,

View File

@@ -0,0 +1,49 @@
package ru.ysu.lms.academic.course
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.identity.IdentityClient
import ru.ysu.lms.academic.identity.IdentityGroupSummary
import ru.ysu.lms.academic.repo.CourseGroupRepository
import ru.ysu.lms.academic.repo.CourseRepository
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.NotFoundException
import java.util.UUID
/**
* Выбор групп для привязки к курсу. Источник групп — identity-service, а активные
* привязки курса хранятся в academic. Уже привязанные и неактивные группы не предлагаются.
*/
@Service
class CourseGroupService(
private val courses: CourseRepository,
private val groups: CourseGroupRepository,
private val identity: IdentityClient,
) {
@Transactional(readOnly = true)
fun availableGroups(courseId: UUID): List<CourseGroupOption> {
if (!courses.existsById(courseId)) throw NotFoundException("Курс не найден")
val attachedIds = groups.findByCourseIdAndRemovedAtIsNull(courseId).mapTo(HashSet()) { it.groupId.toString() }
return identity
.listGroups()
.filter { it.status == STATUS_ACTIVE && it.id !in attachedIds }
.sortedWith(compareBy<IdentityGroupSummary> { it.name.lowercase() }.thenBy { it.id })
.map { CourseGroupOption(id = it.id, name = it.name, status = it.status) }
}
/** Привязывать можно только существующую активную группу identity. */
@Transactional(readOnly = true)
fun requireAttachable(groupIdValue: String) {
val groupId = parseUuidOrBadRequest(groupIdValue, "идентификатор группы").toString()
val group =
identity
.listGroups()
.firstOrNull { it.id == groupId }
?: throw NotFoundException("Группа не найдена")
if (group.status != STATUS_ACTIVE) throw BadRequestException("Группа неактивна")
}
private companion object {
const val STATUS_ACTIVE = "ACTIVE"
}
}

View File

@@ -42,6 +42,19 @@ class IdentityClient(
throw IdentityUnavailableException("Сервис идентификации вернул некорректный ответ").apply { initCause(e) } throw IdentityUnavailableException("Сервис идентификации вернул некорректный ответ").apply { initCause(e) }
} }
/** Все группы identity для выбора при привязке к курсу. */
fun listGroups(): List<IdentityGroupSummary> =
try {
client
.get()
.uri("/api/v1/internal/groups")
.header(INTERNAL_TOKEN_HEADER, internalToken)
.retrieve()
.body(GROUP_SUMMARY_LIST) ?: emptyList()
} catch (e: RestClientException) {
throw IdentityUnavailableException().apply { initCause(e) }
}
/** Текущий состав группы через внутренний endpoint identity. */ /** Текущий состав группы через внутренний endpoint identity. */
fun getGroupRoster(groupId: String): GroupRosterInfo = fun getGroupRoster(groupId: String): GroupRosterInfo =
try { try {
@@ -70,6 +83,14 @@ internal data class GroupRef(
val id: String = "", val id: String = "",
) )
/** Минимальный контракт `GET /api/v1/internal/groups`. */
@JsonIgnoreProperties(ignoreUnknown = true)
data class IdentityGroupSummary(
val id: String = "",
val name: String = "",
val status: String = "",
)
/** Минимальный контракт `GET /api/v1/internal/groups/{groupId}`. */ /** Минимальный контракт `GET /api/v1/internal/groups/{groupId}`. */
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class GroupRosterInfo( data class GroupRosterInfo(
@@ -88,6 +109,8 @@ data class GroupRosterStudent(
private val GROUP_LIST = object : ParameterizedTypeReference<List<GroupRef>>() {} private val GROUP_LIST = object : ParameterizedTypeReference<List<GroupRef>>() {}
private val GROUP_SUMMARY_LIST = object : ParameterizedTypeReference<List<IdentityGroupSummary>>() {}
private const val INTERNAL_TOKEN_HEADER = "X-Internal-Token" private const val INTERNAL_TOKEN_HEADER = "X-Internal-Token"
/** Таймауты обязательны: зависший downstream держит поток запроса и вешает сервис. */ /** Таймауты обязательны: зависший downstream держит поток запроса и вешает сервис. */

View File

@@ -18,6 +18,8 @@ interface ContentChapterRepository : JpaRepository<ContentChapter, UUID> {
fun findByCourseIdOrderByPositionAscIdAsc(courseId: UUID): List<ContentChapter> fun findByCourseIdOrderByPositionAscIdAsc(courseId: UUID): List<ContentChapter>
fun findByCourseIdIsNullOrderByPositionAscIdAsc(): List<ContentChapter>
fun findByCourseIdAndSlug( fun findByCourseIdAndSlug(
courseId: UUID, courseId: UUID,
slug: String, slug: String,

View File

@@ -30,6 +30,8 @@ interface CourseTeacherRepository : JpaRepository<CourseTeacher, UUID> {
interface CourseGroupRepository : JpaRepository<CourseGroup, UUID> { interface CourseGroupRepository : JpaRepository<CourseGroup, UUID> {
fun findByCourseIdOrderByAddedAtAsc(courseId: UUID): List<CourseGroup> fun findByCourseIdOrderByAddedAtAsc(courseId: UUID): List<CourseGroup>
fun findByCourseIdAndRemovedAtIsNull(courseId: UUID): List<CourseGroup>
fun findByCourseIdAndGroupIdAndRemovedAtIsNull( fun findByCourseIdAndGroupIdAndRemovedAtIsNull(
courseId: UUID, courseId: UUID,
groupId: UUID, groupId: UUID,

View File

@@ -0,0 +1,79 @@
package ru.ysu.lms.academic.audit
import org.assertj.core.api.Assertions.assertThatCode
import org.hamcrest.Matchers.containsString
import org.junit.jupiter.api.Test
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.test.web.client.MockRestServiceServer
import org.springframework.test.web.client.match.MockRestRequestMatchers.content
import org.springframework.test.web.client.match.MockRestRequestMatchers.header
import org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath
import org.springframework.test.web.client.match.MockRestRequestMatchers.method
import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
import org.springframework.web.client.RestClient
/**
* Аудит — best-effort: проверяем контракт запроса и то, что ошибки identity
* не пробрасываются в бизнес-операцию.
*/
class AuditClientTest {
private val actorId = "11111111-1111-1111-1111-111111111111"
private val courseId = "22222222-2222-2222-2222-222222222222"
@Test
fun `record sends actor action and entity to identity internal audit endpoint`() {
val builder = RestClient.builder().baseUrl(IDENTITY_URL)
val server = MockRestServiceServer.bindTo(builder).build()
val client = AuditClient(builder.build(), INTERNAL_TOKEN)
server
.expect(requestTo("$IDENTITY_URL/api/v1/internal/audit"))
.andExpect(method(HttpMethod.POST))
.andExpect(header("X-Internal-Token", INTERNAL_TOKEN))
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.actorAccountId").value(actorId))
.andExpect(content().string(containsString("\"actorLogin\":null")))
.andExpect(jsonPath("$.action").value("COURSE_TEACHER_ASSIGN"))
.andExpect(jsonPath("$.entityType").value("COURSE"))
.andExpect(jsonPath("$.entityId").value(courseId))
.andExpect(jsonPath("$.courseId").value(courseId))
.andExpect(jsonPath("$.details").value("accountId=$actorId"))
.andRespond(withStatus(HttpStatus.NO_CONTENT))
client.record(
actorAccountId = actorId,
action = "COURSE_TEACHER_ASSIGN",
entityType = "COURSE",
entityId = courseId,
courseId = courseId,
details = "accountId=$actorId",
)
server.verify()
}
@Test
fun `record swallows identity errors`() {
val builder = RestClient.builder().baseUrl(IDENTITY_URL)
val server = MockRestServiceServer.bindTo(builder).build()
val client = AuditClient(builder.build(), INTERNAL_TOKEN)
server
.expect(requestTo("$IDENTITY_URL/api/v1/internal/audit"))
.andRespond(withStatus(HttpStatus.FORBIDDEN))
assertThatCode {
client.record(actorId, "LAB_DELETE", "LAB", courseId)
}.doesNotThrowAnyException()
server.verify()
}
private companion object {
private const val IDENTITY_URL = "http://identity.test"
private const val INTERNAL_TOKEN = "test-internal-token"
}
}

View File

@@ -0,0 +1,447 @@
package ru.ysu.lms.academic.content
import com.jayway.jsonpath.JsonPath
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.http.MediaType
import org.springframework.security.core.authority.SimpleGrantedAuthority
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.MvcResult
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
import org.springframework.test.web.servlet.request.RequestPostProcessor
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.ContentChapter
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
/**
* Авторский контент курса: преподаватель пишет только в свои курсы, студент — никогда,
* администратор не ограничен; глобальные материалы остаются админскими.
*/
@Testcontainers
@SpringBootTest
@Transactional
class CourseContentAuthoringIntegrationTest {
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 courseService: CourseService
@Autowired
private lateinit var contentService: ContentService
@Autowired
private lateinit var authoring: ContentAuthoringService
@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 `teacher creates chapter section and article in own course`() {
val courseId = newCourse()
val teacher = teacherOf(courseId)
val chapterId = createChapter(courseId, "Глава преподавателя", teacher)
val sectionId = createSection(courseId, chapterId, "Раздел преподавателя", teacher)
val articleId = createArticle(courseId, """{"sectionId":"$sectionId","title":"Статья","bodyMd":"Текст"}""", teacher)
val chapter = chapters.findById(chapterId).orElseThrow()
assertThat(chapter.courseId).isEqualTo(courseId)
assertThat(chapter.audience).isNull()
assertThat(sections.findById(sectionId).orElseThrow().chapterId).isEqualTo(chapterId)
assertThat(articles.findById(articleId).orElseThrow().sectionId).isEqualTo(sectionId)
mockMvc
.perform(get("/api/v1/courses/{courseId}/content/tree", courseId).with(teacher))
.andExpect(status().isOk)
.andExpect(jsonPath("$.chapters.length()").value(1))
.andExpect(jsonPath("$.chapters[0].id").value(chapterId.toString()))
.andExpect(jsonPath("$.chapters[0].sections[0].id").value(sectionId.toString()))
.andExpect(jsonPath("$.chapters[0].sections[0].articles[0].id").value(articleId.toString()))
}
@Test
fun `teacher can create new chapter and section while placing article`() {
val courseId = newCourse()
val teacher = teacherOf(courseId)
val result =
mockMvc
.perform(
post("/api/v1/courses/{courseId}/content/articles", courseId)
.contentType(MediaType.APPLICATION_JSON)
.content(
"""{"newChapterTitle":"Новая глава","newSectionTitle":"Новый раздел","title":"Статья","bodyMd":"Текст"}""",
).with(teacher),
).andExpect(status().isOk)
.andReturn()
val articleId = idOf(result)
val tree = contentService.courseTree(courseId, includeHidden = true)
val chapter = tree.chapters.single()
val section = chapter.sections.single()
assertThat(chapter.title).isEqualTo("Новая глава")
assertThat(section.title).isEqualTo("Новый раздел")
assertThat(section.articles.single().id).isEqualTo(articleId.toString())
assertThat(chapters.findByCourseIdOrderByPositionAscIdAsc(courseId)).hasSize(1)
}
@Test
fun `foreign teacher cannot author course content`() {
val courseId = newCourse()
val foreign = jwtFor(UUID.randomUUID(), "TEACHER")
mockMvc
.perform(
post("/api/v1/courses/{courseId}/content/chapters", courseId)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"title":"Чужая глава"}""")
.with(foreign),
).andExpect(status().isForbidden)
mockMvc
.perform(
post("/api/v1/courses/{courseId}/content/articles", courseId)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"newChapterTitle":"Чужая глава","newSectionTitle":"Чужой раздел","title":"Статья"}""")
.with(foreign),
).andExpect(status().isForbidden)
}
@Test
fun `student cannot author course content`() {
val courseId = newCourse()
val chapterId = authoring.createChapter("Глава курса", null, courseId).let { UUID.fromString(it.id) }
val student = jwtFor(UUID.randomUUID(), "STUDENT")
mockMvc
.perform(
post("/api/v1/courses/{courseId}/content/chapters", courseId)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"title":"Глава студента"}""")
.with(student),
).andExpect(status().isForbidden)
mockMvc
.perform(
post("/api/v1/courses/{courseId}/content/articles", courseId)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"chapterId":"$chapterId","newSectionTitle":"Раздел","title":"Статья"}""")
.with(student),
).andExpect(status().isForbidden)
mockMvc
.perform(
delete("/api/v1/courses/{courseId}/content/chapters/{id}", courseId, chapterId)
.with(student),
).andExpect(status().isForbidden)
}
@Test
fun `teacher cannot change chapter or section of another course`() {
val ownCourseId = newCourse()
val ownTeacher = teacherOf(ownCourseId)
val ownChapterId = createChapter(ownCourseId, "Своя глава", ownTeacher)
val foreign = seedForeignMaterial()
mockMvc
.perform(
put("/api/v1/courses/{courseId}/content/chapters/{id}", ownCourseId, foreign.chapterId)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"title":"Подмена"}""")
.with(ownTeacher),
).andExpect(status().isForbidden)
mockMvc
.perform(
delete("/api/v1/courses/{courseId}/content/chapters/{id}", ownCourseId, foreign.chapterId)
.with(ownTeacher),
).andExpect(status().isForbidden)
mockMvc
.perform(
put("/api/v1/courses/{courseId}/content/sections/{id}", ownCourseId, foreign.sectionId)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"title":"Подмена"}""")
.with(ownTeacher),
).andExpect(status().isForbidden)
mockMvc
.perform(
put("/api/v1/courses/{courseId}/content/chapters/{id}", foreign.courseId, ownChapterId)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"title":"Чужой курс"}""")
.with(ownTeacher),
).andExpect(status().isForbidden)
}
@Test
fun `teacher cannot change article of another course`() {
val ownCourseId = newCourse()
val ownTeacher = teacherOf(ownCourseId)
val foreign = seedForeignMaterial()
mockMvc
.perform(
put("/api/v1/courses/{courseId}/content/articles/{id}", ownCourseId, foreign.articleId)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"title":"Подмена","bodyMd":"Текст"}""")
.with(ownTeacher),
).andExpect(status().isForbidden)
mockMvc
.perform(
delete("/api/v1/courses/{courseId}/content/articles/{id}", ownCourseId, foreign.articleId)
.with(ownTeacher),
).andExpect(status().isForbidden)
}
@Test
fun `published toggle hides article from student tree`() {
val courseId = newCourse()
val teacher = teacherOf(courseId)
val groupId = UUID.randomUUID()
courseService.attachGroup(courseId, CourseGroupRequest(groupId.toString()))
`when`(identity.currentGroupIds(anyString())).thenReturn(listOf(groupId))
val student = jwtFor(UUID.randomUUID(), "STUDENT")
val chapterId = createChapter(courseId, "Глава", teacher)
val sectionId = createSection(courseId, chapterId, "Раздел", teacher)
val articleId = createArticle(courseId, """{"sectionId":"$sectionId","title":"Статья"}""", teacher)
mockMvc
.perform(get("/api/v1/courses/{courseId}/content/tree", courseId).with(student))
.andExpect(status().isOk)
.andExpect(jsonPath("$.chapters[0].sections[0].articles[0].id").value(articleId.toString()))
mockMvc
.perform(
put("/api/v1/courses/{courseId}/content/articles/{id}/published", courseId, articleId)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"published":false}""")
.with(teacher),
).andExpect(status().isNoContent)
mockMvc
.perform(get("/api/v1/courses/{courseId}/content/tree", courseId).with(student))
.andExpect(status().isOk)
.andExpect(jsonPath("$.chapters[0].sections[0].articles.length()").value(0))
mockMvc
.perform(get("/api/v1/courses/{courseId}/content/tree", courseId).with(teacher))
.andExpect(status().isOk)
.andExpect(jsonPath("$.chapters[0].sections[0].articles[0].published").value(false))
mockMvc
.perform(
put("/api/v1/courses/{courseId}/content/sections/{id}/published", courseId, sectionId)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"published":false}""")
.with(teacher),
).andExpect(status().isNoContent)
mockMvc
.perform(get("/api/v1/courses/{courseId}/content/tree", courseId).with(student))
.andExpect(status().isOk)
.andExpect(jsonPath("$.chapters[0].sections.length()").value(0))
}
@Test
fun `global chapter authoring stays admin only`() {
val teacher = jwtFor(UUID.randomUUID(), "TEACHER")
val admin = jwtFor(UUID.randomUUID(), "ADMIN")
mockMvc
.perform(
post("/api/v1/content/chapters")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"title":"Глобальная глава преподавателя"}""")
.with(teacher),
).andExpect(status().isForbidden)
val result =
mockMvc
.perform(
post("/api/v1/content/chapters")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"title":"Глобальная глава администратора"}""")
.with(admin),
).andExpect(status().isOk)
.andReturn()
val chapterId = idOf(result)
val chapter: ContentChapter = chapters.findById(chapterId).orElseThrow()
assertThat(chapter.courseId).isNull()
assertThat(chapter.audience).isEqualTo(AUDIENCE_ALL)
assertThat(contentService.tree().chapters.map { it.id }).contains(chapterId.toString())
}
@Test
fun `admin can author content of any course`() {
val courseId = newCourse()
val admin = jwtFor(UUID.randomUUID(), "ADMIN")
val chapterId = createChapter(courseId, "Глава администратора", admin)
assertThat(chapters.findById(chapterId).orElseThrow().courseId).isEqualTo(courseId)
}
private fun seedForeignMaterial(): ForeignMaterial {
val courseId = newCourse()
val chapterId = authoring.createChapter("Чужая глава", null, courseId).let { UUID.fromString(it.id) }
val sectionId = authoring.createSection(chapterId, "Чужой раздел", null, courseId).let { UUID.fromString(it.id) }
val articleId =
authoring
.createArticlePlaced(
ArticlePlacement(
title = "Чужая статья",
bodyMd = "Текст",
chapterId = null,
newChapterTitle = null,
sectionId = sectionId,
newSectionTitle = null,
),
courseId,
).let { UUID.fromString(it.id) }
return ForeignMaterial(courseId, chapterId, sectionId, articleId)
}
private fun newCourse(): UUID =
UUID.fromString(
courseService
.create(CourseCreateRequest("Курс контента ${UUID.randomUUID()}"), UUID.randomUUID())
.id,
)
private fun teacherOf(courseId: UUID): RequestPostProcessor {
val teacherId = UUID.randomUUID()
courseService.assignTeacher(courseId, CourseTeacherRequest(teacherId.toString()))
return jwtFor(teacherId, "TEACHER")
}
private fun createChapter(
courseId: UUID,
title: String,
user: RequestPostProcessor,
): UUID =
idOf(
mockMvc
.perform(
post("/api/v1/courses/{courseId}/content/chapters", courseId)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"title":"$title"}""")
.with(user),
).andExpect(status().isOk)
.andReturn(),
)
private fun createSection(
courseId: UUID,
chapterId: UUID,
title: String,
user: RequestPostProcessor,
): UUID =
idOf(
mockMvc
.perform(
post("/api/v1/courses/{courseId}/content/sections?chapterId=$chapterId", courseId)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"title":"$title"}""")
.with(user),
).andExpect(status().isOk)
.andReturn(),
)
private fun createArticle(
courseId: UUID,
body: String,
user: RequestPostProcessor,
): UUID =
idOf(
mockMvc
.perform(
post("/api/v1/courses/{courseId}/content/articles", courseId)
.contentType(MediaType.APPLICATION_JSON)
.content(body)
.with(user),
).andExpect(status().isOk)
.andReturn(),
)
private fun idOf(result: MvcResult): UUID {
val id: String = JsonPath.read(result.response.contentAsString, "$.id")
return UUID.fromString(id)
}
private fun jwtFor(
subject: UUID,
role: String,
) = jwt()
.authorities(SimpleGrantedAuthority("ROLE_$role"))
.jwt { it.subject(subject.toString()).claim("role", role) }
private data class ForeignMaterial(
val courseId: UUID,
val chapterId: UUID,
val sectionId: UUID,
val articleId: UUID,
)
}

View File

@@ -0,0 +1,248 @@
package ru.ysu.lms.academic.course
import org.junit.jupiter.api.Test
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.http.MediaType
import org.springframework.security.core.authority.SimpleGrantedAuthority
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.bean.override.mockito.MockitoBean
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
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.identity.IdentityClient
import ru.ysu.lms.academic.identity.IdentityGroupSummary
import java.util.UUID
/**
* Привязка групп к курсу: преподаватель управляет только своими курсами,
* студент не может менять привязки, администратор не ограничен. Identity подменён.
*/
@Testcontainers
@SpringBootTest
@Transactional
class CourseGroupTeacherAccessTest {
companion object {
@Container
@ServiceConnection
@JvmStatic
val postgres = PostgreSQLContainer("postgres:18")
}
@Autowired
private lateinit var context: WebApplicationContext
@Autowired
private lateinit var courseService: CourseService
@MockitoBean
private lateinit var identity: IdentityClient
private val mockMvc: MockMvc by lazy {
MockMvcBuilders
.webAppContextSetup(context)
.apply<DefaultMockMvcBuilder>(springSecurity())
.build()
}
@Test
fun `teacher sees available groups, attaches and detaches on own course`() {
val courseId = newCourse()
val teacherId = UUID.randomUUID()
assignTeacher(courseId, teacherId)
val alpha = UUID.randomUUID()
val beta = UUID.randomUUID()
val graduated = UUID.randomUUID()
`when`(identity.listGroups()).thenReturn(
listOf(
IdentityGroupSummary(beta.toString(), "Бета", "ACTIVE"),
IdentityGroupSummary(graduated.toString(), "Выпускники", "GRADUATED"),
IdentityGroupSummary(alpha.toString(), "Альфа", "ACTIVE"),
),
)
val teacher = jwtFor(teacherId, "TEACHER")
mockMvc
.perform(get("/api/v1/courses/{id}/available-groups", courseId).with(teacher))
.andExpect(status().isOk)
.andExpect(jsonPath("$.length()").value(2))
.andExpect(jsonPath("$[0].id").value(alpha.toString()))
.andExpect(jsonPath("$[0].name").value("Альфа"))
.andExpect(jsonPath("$[0].status").value("ACTIVE"))
.andExpect(jsonPath("$[1].id").value(beta.toString()))
mockMvc
.perform(
post("/api/v1/courses/{id}/groups", courseId)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"groupId":"$alpha"}""")
.with(teacher),
).andExpect(status().isOk)
.andExpect(jsonPath("$.groups.length()").value(1))
.andExpect(jsonPath("$.groups[0].groupId").value(alpha.toString()))
mockMvc
.perform(get("/api/v1/courses/{id}/available-groups", courseId).with(teacher))
.andExpect(status().isOk)
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].id").value(beta.toString()))
mockMvc
.perform(
delete("/api/v1/courses/{id}/groups/{groupId}", courseId, alpha)
.with(teacher),
).andExpect(status().isOk)
.andExpect(jsonPath("$.groups[0].removedAt").exists())
}
@Test
fun `foreign teacher cannot list attach or detach groups`() {
val courseId = newCourse()
val groupId = UUID.randomUUID()
`when`(identity.listGroups()).thenReturn(listOf(IdentityGroupSummary(groupId.toString(), "Альфа", "ACTIVE")))
val foreign = jwtFor(UUID.randomUUID(), "TEACHER")
mockMvc
.perform(get("/api/v1/courses/{id}/available-groups", courseId).with(foreign))
.andExpect(status().isForbidden)
mockMvc
.perform(
post("/api/v1/courses/{id}/groups", courseId)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"groupId":"$groupId"}""")
.with(foreign),
).andExpect(status().isForbidden)
mockMvc
.perform(delete("/api/v1/courses/{id}/groups/{groupId}", courseId, groupId).with(foreign))
.andExpect(status().isForbidden)
}
@Test
fun `student cannot list attach or detach groups`() {
val courseId = newCourse()
val groupId = UUID.randomUUID()
`when`(identity.listGroups()).thenReturn(listOf(IdentityGroupSummary(groupId.toString(), "Альфа", "ACTIVE")))
val student = jwtFor(UUID.randomUUID(), "STUDENT")
mockMvc
.perform(get("/api/v1/courses/{id}/available-groups", courseId).with(student))
.andExpect(status().isForbidden)
mockMvc
.perform(
post("/api/v1/courses/{id}/groups", courseId)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"groupId":"$groupId"}""")
.with(student),
).andExpect(status().isForbidden)
mockMvc
.perform(delete("/api/v1/courses/{id}/groups/{groupId}", courseId, groupId).with(student))
.andExpect(status().isForbidden)
}
@Test
fun `admin can list attach and detach groups without assignment`() {
val courseId = newCourse()
val groupId = UUID.randomUUID()
`when`(identity.listGroups()).thenReturn(listOf(IdentityGroupSummary(groupId.toString(), "Альфа", "ACTIVE")))
val admin = jwtFor(UUID.randomUUID(), "ADMIN")
mockMvc
.perform(get("/api/v1/courses/{id}/available-groups", courseId).with(admin))
.andExpect(status().isOk)
.andExpect(jsonPath("$[0].id").value(groupId.toString()))
mockMvc
.perform(
post("/api/v1/courses/{id}/groups", courseId)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"groupId":"$groupId"}""")
.with(admin),
).andExpect(status().isOk)
.andExpect(jsonPath("$.groups[0].groupId").value(groupId.toString()))
mockMvc
.perform(delete("/api/v1/courses/{id}/groups/{groupId}", courseId, groupId).with(admin))
.andExpect(status().isOk)
}
@Test
fun `already attached group is excluded from available groups`() {
val courseId = newCourse()
val teacherId = UUID.randomUUID()
assignTeacher(courseId, teacherId)
val attached = UUID.randomUUID()
val free = UUID.randomUUID()
courseService.attachGroup(courseId, CourseGroupRequest(attached.toString()))
`when`(identity.listGroups()).thenReturn(
listOf(
IdentityGroupSummary(attached.toString(), "Привязана", "ACTIVE"),
IdentityGroupSummary(free.toString(), "Свободна", "ACTIVE"),
),
)
mockMvc
.perform(
get("/api/v1/courses/{id}/available-groups", courseId)
.with(jwtFor(teacherId, "TEACHER")),
).andExpect(status().isOk)
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].id").value(free.toString()))
}
@Test
fun `removed group becomes available again`() {
val courseId = newCourse()
val teacherId = UUID.randomUUID()
assignTeacher(courseId, teacherId)
val groupId = UUID.randomUUID()
courseService.attachGroup(courseId, CourseGroupRequest(groupId.toString()))
courseService.detachGroup(courseId, groupId.toString())
`when`(identity.listGroups()).thenReturn(listOf(IdentityGroupSummary(groupId.toString(), "Возвращена", "ACTIVE")))
mockMvc
.perform(
get("/api/v1/courses/{id}/available-groups", courseId)
.with(jwtFor(teacherId, "TEACHER")),
).andExpect(status().isOk)
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].id").value(groupId.toString()))
}
private fun newCourse(): UUID =
UUID.fromString(
courseService
.create(CourseCreateRequest("Курс групп ${UUID.randomUUID()}"), UUID.randomUUID())
.id,
)
private fun assignTeacher(
courseId: UUID,
teacherId: UUID,
) {
courseService.assignTeacher(courseId, CourseTeacherRequest(teacherId.toString()))
}
private fun jwtFor(
subject: UUID,
role: String,
) = jwt()
.authorities(SimpleGrantedAuthority("ROLE_$role"))
.jwt { it.subject(subject.toString()).claim("role", role) }
}