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.ContentSectionRepository
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ForbiddenException
import ru.ysu.lms.common.NotFoundException
import java.util.UUID
@@ -32,8 +33,14 @@ class ContentAuthoringService(
articleId: UUID,
title: 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("Заголовок не может быть пустым")
a.title = title.trim()
a.bodyMd = bodyMd
@@ -41,28 +48,48 @@ class ContentAuthoringService(
}
@Transactional
fun deleteArticle(articleId: UUID) {
if (!articles.existsById(articleId)) throw NotFoundException("Статья не найдена")
fun deleteArticle(
articleId: UUID,
courseId: UUID? = null,
) {
if (courseId != null) {
requireArticleInCourse(articleId, courseId)
} else if (!articles.existsById(articleId)) {
throw NotFoundException("Статья не найдена")
}
articles.deleteById(articleId)
}
/**
* Глобальная глава (`courseId == null`) получает аудиторию ALL и уникальный slug
* среди глобальных глав. Курсовая глава получает `courseId`, пустую аудиторию
* и уникальный slug внутри курса.
*/
@Transactional
fun createChapter(
title: String,
slug: String?,
courseId: UUID? = null,
): ChapterTreeNode {
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
val scope =
if (courseId == null) {
chapters.findByCourseIdIsNullOrderByPositionAscIdAsc()
} else {
chapters.findByCourseIdOrderByPositionAscIdAsc(courseId)
}
val cleanSlug =
slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
?: Slugs.uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
?: Slugs.uniqueSlug(title, scope.map { it.slug }.toSet())
val maxPos = scope.maxOfOrNull { it.position } ?: 0
val ch =
chapters.save(
ContentChapter(
title = title.trim(),
slug = cleanSlug,
position = maxPos + 1,
audience = AUDIENCE_ALL,
audience = if (courseId == null) AUDIENCE_ALL else null,
courseId = courseId,
),
)
return ChapterTreeNode(
@@ -79,16 +106,24 @@ class ContentAuthoringService(
fun renameChapter(
chapterId: UUID,
title: String,
courseId: UUID? = null,
) {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
val ch = findChapter(chapterId, courseId)
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
ch.title = title.trim()
chapters.save(ch)
}
@Transactional
fun deleteChapter(chapterId: UUID) {
if (!chapters.existsById(chapterId)) throw NotFoundException("Глава не найдена")
fun deleteChapter(
chapterId: UUID,
courseId: UUID? = null,
) {
if (courseId != null) {
requireChapterInCourse(chapterId, courseId)
} else if (!chapters.existsById(chapterId)) {
throw NotFoundException("Глава не найдена")
}
chapters.deleteById(chapterId)
}
@@ -97,8 +132,9 @@ class ContentAuthoringService(
chapterId: UUID,
title: String,
slug: String?,
courseId: UUID? = null,
): SectionTreeNode {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
val ch = findChapter(chapterId, courseId)
if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым")
val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id)
val cleanSlug =
@@ -123,16 +159,24 @@ class ContentAuthoringService(
fun renameSection(
sectionId: UUID,
title: String,
courseId: UUID? = null,
) {
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
val sec = findSection(sectionId, courseId, "Раздел не найден")
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
sec.title = title.trim()
sections.save(sec)
}
@Transactional
fun deleteSection(sectionId: UUID) {
if (!sections.existsById(sectionId)) throw NotFoundException("Раздел не найден")
fun deleteSection(
sectionId: UUID,
courseId: UUID? = null,
) {
if (courseId != null) {
requireSectionInCourse(sectionId, courseId)
} else if (!sections.existsById(sectionId)) {
throw NotFoundException("Раздел не найден")
}
sections.deleteById(sectionId)
}
@@ -141,9 +185,10 @@ class ContentAuthoringService(
fun moveArticlesAndDeleteSection(
sectionId: UUID,
targetSectionId: UUID,
courseId: UUID? = null,
) {
val src = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
val dst = sections.findById(targetSectionId).orElseThrow { NotFoundException("Раздел назначения не найден") }
val src = findSection(sectionId, courseId, "Раздел не найден")
val dst = findSection(targetSectionId, courseId, "Раздел назначения не найден")
if (src.id == dst.id) throw BadRequestException("Раздел назначения совпадает с исходным")
val moving = articles.findBySectionIdOrderByPositionAscIdAsc(src.id)
var pos = articles.findBySectionIdOrderByPositionAscIdAsc(dst.id).maxOfOrNull { it.position } ?: 0
@@ -156,41 +201,39 @@ class ContentAuthoringService(
sections.delete(src)
}
/** Создаёт статью; если раздела нет — создаёт главу/раздел по названиям. */
/**
* Создаёт статью; если раздела нет — создаёт главу/раздел по названиям.
* При `courseId != null` все вложенные сущности обязаны принадлежать этому курсу,
* а новые главы и разделы создаются в нём.
*/
@Transactional
fun createArticlePlaced(placement: ArticlePlacement): ContentArticleView {
fun createArticlePlaced(
placement: ArticlePlacement,
courseId: UUID? = null,
): ContentArticleView {
val targetSection: ContentSection =
when {
placement.sectionId != null && !placement.newSectionTitle.isNullOrBlank() -> {
val anchor =
sections
.findById(placement.sectionId)
.orElseThrow { NotFoundException("Раздел не найден") }
val node = createSection(anchor.chapterId, placement.newSectionTitle, null)
val anchor = findSection(placement.sectionId, courseId, "Раздел не найден")
val node = createSection(anchor.chapterId, placement.newSectionTitle, null, courseId)
sections
.findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Раздел не найден") }
}
placement.sectionId != null ->
sections
.findById(placement.sectionId)
.orElseThrow { NotFoundException("Раздел не найден") }
placement.sectionId != null -> findSection(placement.sectionId, courseId, "Раздел не найден")
!placement.newSectionTitle.isNullOrBlank() -> {
val chapter: ContentChapter =
when {
placement.chapterId != null ->
chapters
.findById(placement.chapterId)
.orElseThrow { NotFoundException("Глава не найдена") }
placement.chapterId != null -> findChapter(placement.chapterId, courseId)
!placement.newChapterTitle.isNullOrBlank() -> {
val node = createChapter(placement.newChapterTitle, null)
val node = createChapter(placement.newChapterTitle, null, courseId)
chapters
.findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Глава не найдена") }
}
else -> throw BadRequestException("Укажите главу")
}
val node = createSection(chapter.id, placement.newSectionTitle, null)
val node = createSection(chapter.id, placement.newSectionTitle, null, courseId)
sections
.findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Раздел не найден") }
@@ -251,4 +294,55 @@ class ContentAuthoringService(
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.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.GetMapping
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.RestController
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.ContentArticleView
import ru.ysu.lms.academic.content.ContentAuthoringService
import ru.ysu.lms.academic.content.ContentImportResult
import ru.ysu.lms.academic.content.ContentService
@@ -25,6 +29,7 @@ import java.util.UUID
class ContentAdminController(
private val contentService: ContentService,
private val authoring: ContentAuthoringService,
private val audit: AuditClient,
) {
data class ArticleUpdatePayload(
val title: String,
@@ -81,33 +86,66 @@ class ContentAdminController(
fun chapterPublished(
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
@AuthenticationPrincipal jwt: Jwt,
) {
contentService.setChapterPublished(id, body.published)
audit.record(
jwt.subject,
"CONTENT_CHAPTER_PUBLISH",
ENTITY_CHAPTER,
id.toString(),
details = "published=${body.published}",
)
}
@PutMapping("/sections/{id}/published")
fun sectionPublished(
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
@AuthenticationPrincipal jwt: Jwt,
) {
contentService.setSectionPublished(id, body.published)
audit.record(
jwt.subject,
"CONTENT_SECTION_PUBLISH",
ENTITY_SECTION,
id.toString(),
details = "published=${body.published}",
)
}
@PutMapping("/articles/{id}/published")
fun articlePublished(
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
@AuthenticationPrincipal jwt: Jwt,
) {
contentService.setArticlePublished(id, body.published)
audit.record(
jwt.subject,
"CONTENT_ARTICLE_PUBLISH",
ENTITY_ARTICLE,
id.toString(),
details = "published=${body.published}",
)
}
@PostMapping("/import")
fun import(
@RequestParam("file") file: MultipartFile,
@RequestParam(value = "sync", defaultValue = "true") sync: Boolean,
@AuthenticationPrincipal jwt: Jwt,
): ContentImportResult {
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")
@@ -116,19 +154,34 @@ class ContentAdminController(
@PostMapping("/chapters")
fun createChapter(
@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}")
fun renameChapter(
@PathVariable id: UUID,
@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}")
fun deleteChapter(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
authoring.deleteChapter(id)
audit.record(jwt.subject, "CONTENT_CHAPTER_DELETE", ENTITY_CHAPTER, id.toString())
return ResponseEntity.noContent().build()
}
@@ -136,60 +189,107 @@ class ContentAdminController(
fun createSection(
@RequestParam chapterId: UUID,
@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}")
fun renameSection(
@PathVariable id: UUID,
@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}")
fun deleteSection(
@PathVariable id: UUID,
@RequestParam(required = false) moveTo: UUID?,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
if (moveTo != null) {
authoring.moveArticlesAndDeleteSection(id, moveTo)
} else {
authoring.deleteSection(id)
}
audit.record(jwt.subject, "CONTENT_SECTION_DELETE", ENTITY_SECTION, id.toString())
return ResponseEntity.noContent().build()
}
@PostMapping("/articles")
fun createArticle(
@RequestBody body: ArticleCreatePayload,
) = 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,
),
)
@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,
),
)
audit.record(
jwt.subject,
"CONTENT_ARTICLE_CREATE",
ENTITY_ARTICLE,
article.id,
details = "title=${body.title}",
)
return article
}
@PutMapping("/articles/{id}")
fun update(
@PathVariable id: UUID,
@RequestBody body: ArticleUpdatePayload,
@AuthenticationPrincipal jwt: Jwt,
) {
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")
fun move(
@PathVariable id: UUID,
@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}")
fun delete(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<Void> {
authoring.deleteArticle(id)
audit.record(jwt.subject, "CONTENT_ARTICLE_DELETE", ENTITY_ARTICLE, id.toString())
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.RequestMapping
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.CourseBootstrapRequest
import ru.ysu.lms.academic.course.CourseBootstrapResult
import ru.ysu.lms.academic.course.CourseBootstrapService
import ru.ysu.lms.academic.course.CourseCreateRequest
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.CourseGroupRoster
import ru.ysu.lms.academic.course.CourseGroupService
import ru.ysu.lms.academic.course.CourseRosterService
import ru.ysu.lms.academic.course.CourseService
import ru.ysu.lms.academic.course.CourseSummary
@@ -35,6 +38,8 @@ class CourseController(
private val bootstrapService: CourseBootstrapService,
private val accessService: CourseAccessService,
private val rosterService: CourseRosterService,
private val courseGroupService: CourseGroupService,
private val audit: AuditClient,
) {
@GetMapping
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
@@ -51,7 +56,11 @@ class CourseController(
fun create(
@RequestBody body: CourseCreateRequest,
@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}")
fun detail(
@@ -87,42 +96,131 @@ class CourseController(
fun update(
@PathVariable id: UUID,
@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")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun assignTeacher(
@PathVariable id: UUID,
@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}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun removeTeacher(
@PathVariable id: UUID,
@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")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun attachGroup(
@PathVariable id: UUID,
@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}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN','TEACHER')")
fun detachGroup(
@PathVariable id: UUID,
@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")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun bootstrap(
@RequestBody body: CourseBootstrapRequest,
@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 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.RestController
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.lab.LabCopyRequest
import ru.ysu.lms.academic.lab.LabCreateRequest
@@ -34,6 +35,7 @@ import java.util.UUID
class LabController(
private val labService: LabService,
private val accessService: CourseAccessService,
private val audit: AuditClient,
) {
/**
* Без `courseId` список ограничен доступными курсами (admin видит все).
@@ -71,7 +73,16 @@ class LabController(
): LabDetail {
accessService.requireLabAccess(id, 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")
@@ -83,7 +94,16 @@ class LabController(
): LabImportResult {
if (file.isEmpty) throw BadRequestException("Выберите файл")
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}")
@@ -94,6 +114,7 @@ class LabController(
) {
accessService.requireLabAccess(id, jwt)
labService.delete(id)
audit.record(jwt.subject, "LAB_DELETE", ENTITY_LAB, id.toString())
}
@PutMapping("/{id}")
@@ -104,7 +125,16 @@ class LabController(
@AuthenticationPrincipal jwt: Jwt,
): LabDetail {
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")
@@ -115,7 +145,9 @@ class LabController(
@AuthenticationPrincipal jwt: Jwt,
): LabDetail {
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}")
@@ -127,7 +159,15 @@ class LabController(
@AuthenticationPrincipal jwt: Jwt,
): LabDetail {
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}")
@@ -138,7 +178,9 @@ class LabController(
@AuthenticationPrincipal jwt: Jwt,
): LabDetail {
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")
@@ -149,6 +191,7 @@ class LabController(
) {
accessService.requireLabAccess(id, jwt)
labService.toggleStatus(id)
audit.record(jwt.subject, "LAB_PUBLISH", ENTITY_LAB, id.toString())
}
@PostMapping
@@ -158,7 +201,16 @@ class LabController(
@AuthenticationPrincipal jwt: Jwt,
): LabDetail {
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")
@@ -170,6 +222,7 @@ class LabController(
): LabDetail {
accessService.requireLabAccess(id, jwt)
labService.addLevel(id, body)
audit.record(jwt.subject, "LAB_LEVEL_ADD", ENTITY_LAB, id.toString(), details = "code=${body.code}")
return labService.detail(id)
}
@@ -182,7 +235,9 @@ class LabController(
@AuthenticationPrincipal jwt: Jwt,
): LabDetail {
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}")
@@ -193,7 +248,9 @@ class LabController(
@AuthenticationPrincipal jwt: Jwt,
): LabDetail {
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("Укажите курс")
return null
}
private companion object {
private const val ENTITY_LAB = "LAB"
}
}

View File

@@ -52,6 +52,13 @@ data class CourseGroupRequest(
val groupId: String,
)
/** Группа, которую можно привязать к курсу: ещё не привязана и активна в identity. */
data class CourseGroupOption(
val id: String,
val name: String,
val status: String,
)
data class CourseBootstrapRequest(
val groupIds: List<String> = emptyList(),
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) }
}
/** Все группы 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. */
fun getGroupRoster(groupId: String): GroupRosterInfo =
try {
@@ -70,6 +83,14 @@ internal data class GroupRef(
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}`. */
@JsonIgnoreProperties(ignoreUnknown = true)
data class GroupRosterInfo(
@@ -88,6 +109,8 @@ data class GroupRosterStudent(
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"
/** Таймауты обязательны: зависший downstream держит поток запроса и вешает сервис. */

View File

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

View File

@@ -30,6 +30,8 @@ interface CourseTeacherRepository : JpaRepository<CourseTeacher, UUID> {
interface CourseGroupRepository : JpaRepository<CourseGroup, UUID> {
fun findByCourseIdOrderByAddedAtAsc(courseId: UUID): List<CourseGroup>
fun findByCourseIdAndRemovedAtIsNull(courseId: UUID): List<CourseGroup>
fun findByCourseIdAndGroupIdAndRemovedAtIsNull(
courseId: 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) }
}