feat(academic): права преподавателя, группы курса и аудит
This commit is contained in:
@@ -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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 держит поток запроса и вешает сервис. */
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user