feat(academic): курсы, bootstrap и привязка лабораторных

This commit is contained in:
2026-10-27 15:37:46 +03:00
parent 0c68702e8f
commit 0c51bf2d12
18 changed files with 1289 additions and 15 deletions

View File

@@ -0,0 +1,85 @@
package ru.ysu.lms.academic.controller
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
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.RestController
import ru.ysu.lms.academic.course.CourseBootstrapRequest
import ru.ysu.lms.academic.course.CourseBootstrapResult
import ru.ysu.lms.academic.course.CourseBootstrapService
import ru.ysu.lms.academic.course.CourseCreateRequest
import ru.ysu.lms.academic.course.CourseDetail
import ru.ysu.lms.academic.course.CourseGroupRequest
import ru.ysu.lms.academic.course.CourseService
import ru.ysu.lms.academic.course.CourseSummary
import ru.ysu.lms.academic.course.CourseTeacherRequest
import ru.ysu.lms.academic.course.CourseUpdateRequest
import ru.ysu.lms.academic.course.parseUuidOrBadRequest
import java.util.UUID
@RestController
@RequestMapping("/api/v1/courses")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
class CourseController(
private val courseService: CourseService,
private val bootstrapService: CourseBootstrapService,
) {
@GetMapping
fun list(): List<CourseSummary> = courseService.list()
@PostMapping
fun create(
@RequestBody body: CourseCreateRequest,
@AuthenticationPrincipal jwt: Jwt,
): CourseDetail = courseService.create(body, ownerAccountId(jwt))
@GetMapping("/{id}")
fun detail(
@PathVariable id: UUID,
): CourseDetail = courseService.detail(id)
@PutMapping("/{id}")
fun update(
@PathVariable id: UUID,
@RequestBody body: CourseUpdateRequest,
): CourseDetail = courseService.update(id, body)
@PostMapping("/{id}/teachers")
fun assignTeacher(
@PathVariable id: UUID,
@RequestBody body: CourseTeacherRequest,
): CourseDetail = courseService.assignTeacher(id, body)
@DeleteMapping("/{id}/teachers/{accountId}")
fun removeTeacher(
@PathVariable id: UUID,
@PathVariable accountId: String,
): CourseDetail = courseService.removeTeacher(id, accountId)
@PostMapping("/{id}/groups")
fun attachGroup(
@PathVariable id: UUID,
@RequestBody body: CourseGroupRequest,
): CourseDetail = courseService.attachGroup(id, body)
@DeleteMapping("/{id}/groups/{groupId}")
fun detachGroup(
@PathVariable id: UUID,
@PathVariable groupId: String,
): CourseDetail = courseService.detachGroup(id, groupId)
@PostMapping("/bootstrap")
fun bootstrap(
@RequestBody body: CourseBootstrapRequest,
@AuthenticationPrincipal jwt: Jwt,
): CourseBootstrapResult = bootstrapService.bootstrap(body, ownerAccountId(jwt))
private fun ownerAccountId(jwt: Jwt): UUID = parseUuidOrBadRequest(jwt.subject ?: "", "идентификатор пользователя в токене")
}

View File

@@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.academic.lab.LabCopyRequest
import ru.ysu.lms.academic.lab.LabCreateRequest
import ru.ysu.lms.academic.lab.LabDetail
import ru.ysu.lms.academic.lab.LabImportResult
@@ -30,20 +31,30 @@ class LabController(
private val labService: LabService,
) {
@GetMapping
fun list(): List<LabSummary> = labService.list()
fun list(
@RequestParam(required = false) courseId: UUID?,
): List<LabSummary> = labService.list(courseId)
@GetMapping("/{id}")
fun detail(
@PathVariable id: UUID,
): LabDetail = labService.detail(id)
@PostMapping("/{id}/copy")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun copy(
@PathVariable id: UUID,
@Valid @RequestBody body: LabCopyRequest,
): LabDetail = labService.copy(id, body)
@PostMapping("/import")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun importLab(
@RequestParam("file") file: MultipartFile,
@RequestParam(required = false) courseId: UUID?,
): LabImportResult {
if (file.isEmpty) throw BadRequestException("Выберите файл")
return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes)
return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes, courseId)
}
@DeleteMapping("/{id}")

View File

@@ -0,0 +1,148 @@
package ru.ysu.lms.academic.course
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.Course
import ru.ysu.lms.academic.domain.CourseGroup
import ru.ysu.lms.academic.repo.CourseGroupRepository
import ru.ysu.lms.academic.repo.CourseRepository
import ru.ysu.lms.common.ConflictException
import java.util.UUID
/**
* Единоразовый (но идемпотентный) перенос существующих лабораторных и глав
* в курс «Промышленное программирование на языке C».
*/
@Service
class CourseBootstrapService(
private val courses: CourseRepository,
private val courseGroups: CourseGroupRepository,
private val courseService: CourseService,
private val jdbc: JdbcTemplate,
) {
@Transactional
fun bootstrap(
request: CourseBootstrapRequest,
ownerAccountId: UUID,
): CourseBootstrapResult {
// Сериализуем конкурентные bootstrap-запросы в рамках транзакции.
jdbc.execute("SELECT pg_advisory_xact_lock(hashtext('academic.course.bootstrap'))")
// Сначала проверяем все идентификаторы, чтобы не создать курс при битом запросе.
val groupIds =
request.groupIds
.map { parseUuidOrBadRequest(it, "идентификатор группы") }
.distinct()
val title = request.title?.trim()?.takeIf { it.isNotEmpty() } ?: DEFAULT_TITLE
val course = findOrCreateCourse(title, ownerAccountId)
val attachedGroups = attachGroups(course, groupIds)
val duplicateNumbers = findDuplicateLabNumbers()
if (duplicateNumbers.isNotEmpty()) {
throw ConflictException(
"Нельзя выполнить bootstrap: дубли номеров лабораторных без курса: ${duplicateNumbers.joinToString(", ")}",
)
}
val assignedLabs = assignLabs(course.id)
val assignedChapters = assignChapters(course.id)
val labsWithoutCourse = countLabsWithoutCourse()
val chaptersWithoutCourse = countChaptersWithoutCourse()
if (labsWithoutCourse == 0L && isLabsCourseIdNullable()) {
jdbc.execute("ALTER TABLE academic.labs ALTER COLUMN course_id SET NOT NULL")
}
return CourseBootstrapResult(
courseId = course.id.toString(),
courseNo = course.courseNo ?: courseService.loadCourseNo(course.id),
title = course.title,
attachedGroups = attachedGroups,
assignedLabs = assignedLabs,
assignedChapters = assignedChapters,
labsWithoutCourse = labsWithoutCourse,
chaptersWithoutCourse = chaptersWithoutCourse,
)
}
private fun findOrCreateCourse(
title: String,
ownerAccountId: UUID,
): Course {
courses.findByTitleIgnoreCase(title)?.let { return it }
return try {
courses
.saveAndFlush(Course(title = title, ownerAccountId = ownerAccountId))
.also { it.courseNo = courseService.loadCourseNo(it.id) }
} catch (e: DataIntegrityViolationException) {
// Конкурентный bootstrap мог вставить курс между SELECT и INSERT.
courses.findByTitleIgnoreCase(title) ?: throw e
}
}
private fun attachGroups(
course: Course,
groupIds: List<UUID>,
): Int {
var attached = 0
groupIds.forEach { groupId ->
if (courseGroups.findByCourseIdAndGroupIdAndRemovedAtIsNull(course.id, groupId) == null) {
courseGroups.save(CourseGroup(course = course, groupId = groupId))
attached += 1
}
}
return attached
}
private fun assignLabs(courseId: UUID): Int =
try {
jdbc.update("UPDATE academic.labs SET course_id = ? WHERE course_id IS NULL", courseId)
} catch (e: DataIntegrityViolationException) {
throw ConflictException(
"Не удалось привязать лабораторные к курсу: есть дубли номеров. Устраните их и повторите bootstrap.",
).apply { initCause(e) }
}
private fun assignChapters(courseId: UUID): Int =
jdbc.update(
"UPDATE academic.chapters SET course_id = ?, audience = NULL WHERE course_id IS NULL",
courseId,
)
private fun countLabsWithoutCourse(): Long =
jdbc.queryForObject("SELECT count(*) FROM academic.labs WHERE course_id IS NULL", Long::class.java) ?: 0L
private fun findDuplicateLabNumbers(): List<Int> =
jdbc
.queryForList(
"""
SELECT number
FROM academic.labs
WHERE course_id IS NULL
GROUP BY number
HAVING count(*) > 1
ORDER BY number
""".trimIndent(),
Int::class.java,
).mapNotNull { it }
private fun countChaptersWithoutCourse(): Long =
jdbc.queryForObject("SELECT count(*) FROM academic.chapters WHERE course_id IS NULL", Long::class.java) ?: 0L
private fun isLabsCourseIdNullable(): Boolean {
val nullable =
jdbc.queryForObject(
"""
SELECT is_nullable
FROM information_schema.columns
WHERE table_schema = 'academic'
AND table_name = 'labs'
AND column_name = 'course_id'
""".trimIndent(),
String::class.java,
)
return nullable == "YES"
}
companion object {
const val DEFAULT_TITLE = "Промышленное программирование на языке C"
}
}

View File

@@ -0,0 +1,69 @@
package ru.ysu.lms.academic.course
data class CourseSummary(
val id: String,
val courseNo: Long,
val title: String,
val ownerAccountId: String,
val status: String,
val teacherCount: Long,
val groupCount: Long,
val labCount: Long,
)
data class CourseTeacherDto(
val accountId: String,
val assignedAt: String,
val removedAt: String? = null,
)
data class CourseGroupDto(
val groupId: String,
val addedAt: String,
val removedAt: String? = null,
)
data class CourseDetail(
val id: String,
val courseNo: Long,
val title: String,
val ownerAccountId: String,
val status: String,
val createdAt: String,
val updatedAt: String,
val teachers: List<CourseTeacherDto>,
val groups: List<CourseGroupDto>,
)
data class CourseCreateRequest(
val title: String? = null,
)
data class CourseUpdateRequest(
val title: String? = null,
val status: String? = null,
)
data class CourseTeacherRequest(
val accountId: String,
)
data class CourseGroupRequest(
val groupId: String,
)
data class CourseBootstrapRequest(
val groupIds: List<String> = emptyList(),
val title: String? = null,
)
data class CourseBootstrapResult(
val courseId: String,
val courseNo: Long,
val title: String,
val attachedGroups: Int,
val assignedLabs: Int,
val assignedChapters: Int,
val labsWithoutCourse: Long,
val chaptersWithoutCourse: Long,
)

View File

@@ -0,0 +1,189 @@
package ru.ysu.lms.academic.course
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.Course
import ru.ysu.lms.academic.domain.CourseGroup
import ru.ysu.lms.academic.domain.CourseTeacher
import ru.ysu.lms.academic.repo.CourseGroupRepository
import ru.ysu.lms.academic.repo.CourseRepository
import ru.ysu.lms.academic.repo.CourseTeacherRepository
import ru.ysu.lms.academic.repo.LabRepository
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.NotFoundException
import java.time.Instant
import java.util.UUID
@Service
class CourseService(
private val courses: CourseRepository,
private val teachers: CourseTeacherRepository,
private val groups: CourseGroupRepository,
private val labs: LabRepository,
private val jdbc: JdbcTemplate,
) {
@Transactional(readOnly = true)
fun list(): List<CourseSummary> =
courses.findAllByOrderByCourseNoAsc().map { course ->
CourseSummary(
id = course.id.toString(),
courseNo = requireCourseNo(course),
title = course.title,
ownerAccountId = course.ownerAccountId.toString(),
status = course.status,
teacherCount = teachers.countByCourseIdAndRemovedAtIsNull(course.id),
groupCount = groups.countByCourseIdAndRemovedAtIsNull(course.id),
labCount = labs.countByCourseId(course.id),
)
}
@Transactional
fun create(
request: CourseCreateRequest,
ownerAccountId: UUID,
): CourseDetail {
val title = normalizeTitle(request.title) ?: DEFAULT_TITLE
if (courses.findByTitleIgnoreCase(title) != null) {
throw ConflictException("Курс с названием «$title» уже существует")
}
val course =
try {
courses.saveAndFlush(Course(title = title, ownerAccountId = ownerAccountId))
} catch (e: DataIntegrityViolationException) {
// Конкурентная вставка между проверкой и saveAndFlush ловится unique-индексом.
throw ConflictException("Курс с названием «$title» уже существует").apply { initCause(e) }
}
course.courseNo = loadCourseNo(course.id)
return toDetail(course)
}
@Transactional(readOnly = true)
fun detail(courseId: UUID): CourseDetail = toDetail(findCourse(courseId))
@Transactional
fun update(
courseId: UUID,
request: CourseUpdateRequest,
): CourseDetail {
val course = findCourse(courseId)
request.title?.let {
course.title = normalizeTitle(it) ?: throw BadRequestException("Название курса не может быть пустым")
}
request.status?.let { course.status = normalizeStatus(it) }
course.updatedAt = Instant.now()
courses.save(course)
return toDetail(course)
}
@Transactional
fun assignTeacher(
courseId: UUID,
request: CourseTeacherRequest,
): CourseDetail {
val course = findActiveCourse(courseId)
val accountId = parseUuidOrBadRequest(request.accountId, "идентификатор преподавателя")
if (teachers.findByCourseIdAndAccountIdAndRemovedAtIsNull(course.id, accountId) == null) {
teachers.save(CourseTeacher(course = course, accountId = accountId))
}
return toDetail(course)
}
@Transactional
fun removeTeacher(
courseId: UUID,
accountIdValue: String,
): CourseDetail {
val course = findCourse(courseId)
val accountId = parseUuidOrBadRequest(accountIdValue, "идентификатор преподавателя")
val assignment =
teachers.findByCourseIdAndAccountIdAndRemovedAtIsNull(course.id, accountId)
?: throw NotFoundException("Преподаватель не назначен на курс")
assignment.removedAt = Instant.now()
teachers.save(assignment)
return toDetail(course)
}
@Transactional
fun attachGroup(
courseId: UUID,
request: CourseGroupRequest,
): CourseDetail {
val course = findActiveCourse(courseId)
val groupId = parseUuidOrBadRequest(request.groupId, "идентификатор группы")
if (groups.findByCourseIdAndGroupIdAndRemovedAtIsNull(course.id, groupId) == null) {
groups.save(CourseGroup(course = course, groupId = groupId))
}
return toDetail(course)
}
@Transactional
fun detachGroup(
courseId: UUID,
groupIdValue: String,
): CourseDetail {
val course = findCourse(courseId)
val groupId = parseUuidOrBadRequest(groupIdValue, "идентификатор группы")
val assignment =
groups.findByCourseIdAndGroupIdAndRemovedAtIsNull(course.id, groupId)
?: throw NotFoundException("Группа не привязана к курсу")
assignment.removedAt = Instant.now()
groups.save(assignment)
return toDetail(course)
}
internal fun loadCourseNo(courseId: UUID): Long =
jdbc.queryForObject("SELECT course_no FROM academic.courses WHERE id = ?", Long::class.java, courseId)
?: error("course_no не сгенерирован для курса $courseId")
private fun findCourse(courseId: UUID): Course = courses.findById(courseId).orElseThrow { NotFoundException("Курс не найден") }
private fun findActiveCourse(courseId: UUID): Course {
val course = findCourse(courseId)
if (course.status != Course.STATUS_ACTIVE) throw BadRequestException("Курс архивирован")
return course
}
private fun toDetail(course: Course): CourseDetail =
CourseDetail(
id = course.id.toString(),
courseNo = requireCourseNo(course),
title = course.title,
ownerAccountId = course.ownerAccountId.toString(),
status = course.status,
createdAt = course.createdAt.toString(),
updatedAt = course.updatedAt.toString(),
teachers =
teachers.findByCourseIdOrderByAssignedAtAsc(course.id).map {
CourseTeacherDto(
accountId = it.accountId.toString(),
assignedAt = it.assignedAt.toString(),
removedAt = it.removedAt?.toString(),
)
},
groups =
groups.findByCourseIdOrderByAddedAtAsc(course.id).map {
CourseGroupDto(
groupId = it.groupId.toString(),
addedAt = it.addedAt.toString(),
removedAt = it.removedAt?.toString(),
)
},
)
private fun requireCourseNo(course: Course): Long = checkNotNull(course.courseNo) { "course_no не загружен для курса ${course.id}" }
private fun normalizeTitle(value: String?): String? = value?.trim()?.takeIf { it.isNotEmpty() }
private fun normalizeStatus(value: String): String {
val status = value.trim().uppercase()
if (status !in Course.STATUSES) throw BadRequestException("Недопустимый статус курса: $value")
return status
}
companion object {
const val DEFAULT_TITLE = "Новый курс"
}
}

View File

@@ -0,0 +1,12 @@
package ru.ysu.lms.academic.course
import ru.ysu.lms.common.BadRequestException
import java.util.UUID
/** Разбирает UUID из строкового поля запроса, превращая ошибку формата в 400. */
internal fun parseUuidOrBadRequest(
value: String,
what: String,
): UUID =
runCatching { UUID.fromString(value.trim()) }
.getOrElse { throw BadRequestException("Некорректный $what") }

View File

@@ -12,9 +12,11 @@ import java.util.UUID
class ContentChapter(
@Id var id: UUID = Ids.v7(),
@Column(nullable = false, length = 200) var title: String = "",
@Column(nullable = false, unique = true, length = 200) var slug: String = "",
@Column(nullable = false, length = 200) var slug: String = "",
@Column(nullable = false) var position: Int = 0,
@Column(nullable = false) var published: Boolean = true,
@Column(name = "course_id") var courseId: UUID? = null,
@Column(length = 16) var audience: String? = null,
)
@Entity

View File

@@ -0,0 +1,72 @@
package ru.ysu.lms.academic.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID
/** Курс-дисциплина. `courseNo` — человекочитаемый номер для URL, выдаётся БД. */
@Entity
@Table(name = "courses")
class Course(
@Id
var id: UUID = Ids.v7(),
@Column(name = "course_no", insertable = false, updatable = false)
var courseNo: Long? = null,
@Column(nullable = false, length = 200)
var title: String = "",
@Column(name = "owner_account_id", nullable = false)
var ownerAccountId: UUID = UUID(0, 0),
@Column(nullable = false, length = 16)
var status: String = STATUS_ACTIVE,
@Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Instant = Instant.now(),
) {
companion object {
const val STATUS_ACTIVE = "ACTIVE"
const val STATUS_ARCHIVED = "ARCHIVED"
val STATUSES = setOf(STATUS_ACTIVE, STATUS_ARCHIVED)
}
}
/** Назначение преподавателя на курс. Снятие — `removedAt`, история не удаляется. */
@Entity
@Table(name = "course_teachers")
class CourseTeacher(
@Id
var id: UUID = Ids.v7(),
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "course_id", nullable = false)
var course: Course? = null,
@Column(name = "account_id", nullable = false)
var accountId: UUID = UUID(0, 0),
@Column(name = "assigned_at", nullable = false)
var assignedAt: Instant = Instant.now(),
@Column(name = "removed_at")
var removedAt: Instant? = null,
)
/** Привязка группы к курсу. Отвязка — `removedAt`, история не удаляется. */
@Entity
@Table(name = "course_groups")
class CourseGroup(
@Id
var id: UUID = Ids.v7(),
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "course_id", nullable = false)
var course: Course? = null,
@Column(name = "group_id", nullable = false)
var groupId: UUID = UUID(0, 0),
@Column(name = "added_at", nullable = false)
var addedAt: Instant = Instant.now(),
@Column(name = "removed_at")
var removedAt: Instant? = null,
)

View File

@@ -23,6 +23,9 @@ class Lab(
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "semester_id", nullable = false)
var semester: Semester? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id")
var course: Course? = null,
@Column(name = "description_md", nullable = false)
var descriptionMd: String = "",
@Column(name = "deadline_at")

View File

@@ -0,0 +1,119 @@
package ru.ysu.lms.academic.lab
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.Course
import ru.ysu.lms.academic.domain.Lab
import ru.ysu.lms.academic.domain.LabLevel
import ru.ysu.lms.academic.domain.Variant
import ru.ysu.lms.academic.repo.CourseRepository
import ru.ysu.lms.academic.repo.LabLevelRepository
import ru.ysu.lms.academic.repo.LabRepository
import ru.ysu.lms.academic.repo.VariantRepository
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.NotFoundException
import java.util.UUID
/**
* Копирование лабораторной в другой курс. Копируются только описание, уровни и варианты;
* прогресс, сдачи и назначения студентов не переносятся. Копия создаётся черновиком.
*/
@Service
class LabCopyService(
private val labs: LabRepository,
private val labLevels: LabLevelRepository,
private val variantsRepo: VariantRepository,
private val courses: CourseRepository,
) {
@Transactional
fun copy(
sourceLabId: UUID,
request: LabCopyRequest,
): UUID {
val source = requireSource(sourceLabId)
val targetCourse = requireTargetCourse(request.courseId)
validateCopyTarget(source, targetCourse)
val number = request.number ?: source.number
if (number <= 0) throw BadRequestException("Номер лабораторной должен быть положительным")
val title = request.title?.trim()?.takeIf { it.isNotEmpty() } ?: source.title
if (title.isBlank()) throw BadRequestException("Название не может быть пустым")
ensureNumberIsFree(number, targetCourse)
val copy =
labs.save(
Lab(
number = number,
title = title,
semester = source.semester,
course = targetCourse,
descriptionMd = source.descriptionMd,
deadlineAt = source.deadlineAt,
defenseAt = source.defenseAt,
pointsMax = source.pointsMax,
status = "draft",
),
)
copyLevelsAndVariants(source, copy)
return copy.id
}
private fun requireSource(labId: UUID): Lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
private fun requireTargetCourse(courseId: UUID): Course = courses.findById(courseId).orElseThrow { NotFoundException("Курс не найден") }
private fun validateCopyTarget(
source: Lab,
targetCourse: Course,
) {
if (source.course?.id == targetCourse.id) {
throw BadRequestException("Копирование в тот же курс не требуется")
}
if (targetCourse.status == Course.STATUS_ARCHIVED) {
throw BadRequestException("Курс архивирован")
}
}
private fun ensureNumberIsFree(
number: Int,
course: Course,
) {
if (labs.existsByNumberAndCourseId(number, course.id)) {
throw ConflictException("Лабораторная №$number уже существует в целевом курсе")
}
}
private fun copyLevelsAndVariants(
source: Lab,
copy: Lab,
) {
val sourceLevels = labLevels.findByLabIdOrderByPosition(source.id)
val copiedLevels =
sourceLevels.map { level ->
labLevels.save(
LabLevel(
lab = copy,
code = level.code,
title = level.title,
description = level.description,
position = level.position,
pointsMax = level.pointsMax,
),
)
}
val levelMapping = sourceLevels.zip(copiedLevels).associate { (from, to) -> from.id to to }
variantsRepo.findByLabIdOrderByNumber(source.id).forEach { variant ->
val sourceLevel = variant.labLevel ?: return@forEach
val targetLevel = levelMapping[sourceLevel.id] ?: return@forEach
variantsRepo.save(
Variant(
lab = copy,
labLevel = targetLevel,
number = variant.number,
title = variant.title,
statementMd = variant.statementMd,
position = variant.position,
),
)
}
}
}

View File

@@ -1,6 +1,7 @@
package ru.ysu.lms.academic.lab
import java.math.BigDecimal
import java.util.UUID
data class LabMeta(
val number: Int,
@@ -52,6 +53,9 @@ data class LabSummary(
val defenseAt: String?,
val pointsMax: BigDecimal,
val variantCount: Int,
val courseId: String? = null,
val courseNo: Long? = null,
val courseTitle: String? = null,
)
data class LabDetail(
@@ -64,6 +68,9 @@ data class LabDetail(
val defenseAt: String?,
val pointsMax: BigDecimal,
val levels: List<LabLevelDetail>,
val courseId: String? = null,
val courseNo: Long? = null,
val courseTitle: String? = null,
)
data class LabLevelDetail(
@@ -114,6 +121,13 @@ data class LabCreateRequest(
val status: String? = null,
val descriptionMd: String? = null,
val levels: List<LevelSaveRequest> = emptyList(),
val courseId: UUID? = null,
)
data class LabCopyRequest(
val courseId: UUID,
val number: Int? = null,
val title: String? = null,
)
data class LevelRenameRequest(

View File

@@ -2,10 +2,12 @@ package ru.ysu.lms.academic.lab
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.Course
import ru.ysu.lms.academic.domain.Lab
import ru.ysu.lms.academic.domain.LabLevel
import ru.ysu.lms.academic.domain.Semester
import ru.ysu.lms.academic.domain.Variant
import ru.ysu.lms.academic.repo.CourseRepository
import ru.ysu.lms.academic.repo.LabLevelRepository
import ru.ysu.lms.academic.repo.LabRepository
import ru.ysu.lms.academic.repo.SemesterRepository
@@ -20,27 +22,32 @@ import java.time.OffsetDateTime
import java.time.ZoneId
import java.util.UUID
@Suppress("LongParameterList")
@Service
class LabService(
private val labs: LabRepository,
private val labLevels: LabLevelRepository,
private val variantsRepo: VariantRepository,
private val semesters: SemesterRepository,
private val courses: CourseRepository,
private val copyService: LabCopyService,
private val parser: LabImportParser,
) {
@Transactional
fun importFromFile(
filename: String,
bytes: ByteArray,
courseId: UUID? = null,
): LabImportResult {
val bundle = parser.parse(filename, bytes)
val course = courses.resolveCourse(courseId)
val semester =
semesters.findFirstByActiveTrue()
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
if (labs.existsByNumberAndSemesterId(bundle.meta.number, semester.id)) {
if (labs.existsByNumberAndCourseId(bundle.meta.number, course.id)) {
throw ConflictException(
"Лабораторная №${bundle.meta.number} уже существует. Удалите её перед повторным импортом.",
"Лабораторная №${bundle.meta.number} уже существует в этом курсе. Удалите её перед повторным импортом.",
)
}
@@ -50,6 +57,7 @@ class LabService(
number = bundle.meta.number,
title = bundle.meta.title,
semester = semester,
course = course,
descriptionMd = bundle.descriptionMd,
pointsMax = bundle.meta.pointsMax,
status = bundle.meta.status,
@@ -93,8 +101,9 @@ class LabService(
}
@Transactional(readOnly = true)
fun list(): List<LabSummary> =
labs.findAllByOrderByNumberAsc().map { lab ->
fun list(courseId: UUID? = null): List<LabSummary> {
val labList = if (courseId == null) labs.findAllByOrderByNumberAsc() else labs.findAllByCourseIdOrderByNumberAsc(courseId)
return labList.map { lab ->
val variants = variantsRepo.findByLabIdOrderByNumber(lab.id)
LabSummary(
id = lab.id.toString(),
@@ -106,8 +115,12 @@ class LabService(
defenseAt = lab.defenseAt?.toString(),
pointsMax = lab.pointsMax,
variantCount = variants.size,
courseId = lab.course?.id?.toString(),
courseNo = lab.course?.courseNo,
courseTitle = lab.course?.title,
)
}
}
@Transactional(readOnly = true)
fun detail(labId: UUID): LabDetail {
@@ -145,6 +158,9 @@ class LabService(
},
)
},
courseId = lab.course?.id?.toString(),
courseNo = lab.course?.courseNo,
courseTitle = lab.course?.title,
)
}
@@ -283,9 +299,10 @@ class LabService(
@Transactional
fun create(request: LabCreateRequest): LabDetail {
validateCreate(request)
val course = courses.requireCourse(request.courseId)
val semester = findOrCreateSemester(request.semesterTitle.orEmpty(), request.semesterYear ?: 0)
if (labs.existsByNumberAndSemesterId(request.number ?: 0, semester.id)) {
throw ConflictException("Лабораторная №${request.number} уже существует в этом семестре")
if (labs.existsByNumberAndCourseId(request.number ?: 0, course.id)) {
throw ConflictException("Лабораторная №${request.number} уже существует в этом курсе")
}
val lab =
labs.save(
@@ -293,6 +310,7 @@ class LabService(
number = request.number ?: 0,
title = request.title.orEmpty().trim(),
semester = semester,
course = course,
descriptionMd = request.descriptionMd.orEmpty(),
deadlineAt = parseInstant(request.deadlineAt),
defenseAt = parseInstant(request.defenseAt),
@@ -304,6 +322,13 @@ class LabService(
return detail(lab.id)
}
/** Копирует лабораторную, её уровни и варианты в другой курс. Прогресс не копируется. */
@Transactional
fun copy(
sourceLabId: UUID,
request: LabCopyRequest,
): LabDetail = detail(copyService.copy(sourceLabId, request))
@Transactional
fun renameLevel(
labId: UUID,
@@ -375,8 +400,6 @@ class LabService(
return semesters.save(Semester(title = t, year = y, active = true))
}
private fun naturalNumber(value: String): Int = Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
private fun parseInstant(value: String?): Instant? {
val v = value?.takeIf { it.isNotBlank() } ?: return null
return runCatching { Instant.parse(v) }
@@ -385,3 +408,23 @@ class LabService(
.getOrNull()
}
}
/** Явно заданный курс или единственный существующий (для импорта без courseId). */
private fun CourseRepository.resolveCourse(courseId: UUID?): Course {
if (courseId != null) return requireCourse(courseId)
val all = findAllByOrderByCourseNoAsc()
if (all.size == 1) return all.single().rejectArchived()
throw BadRequestException("Укажите курс: в системе должен быть ровно один курс для неявного выбора")
}
private fun CourseRepository.requireCourse(courseId: UUID?): Course {
if (courseId == null) throw BadRequestException("Укажите курс")
return findById(courseId).orElseThrow { NotFoundException("Курс не найден") }.rejectArchived()
}
private fun Course.rejectArchived(): Course {
if (status == Course.STATUS_ARCHIVED) throw BadRequestException("Курс архивирован")
return this
}
private fun naturalNumber(value: String): Int = Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE

View File

@@ -0,0 +1,35 @@
package ru.ysu.lms.academic.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.academic.domain.Course
import ru.ysu.lms.academic.domain.CourseGroup
import ru.ysu.lms.academic.domain.CourseTeacher
import java.util.UUID
interface CourseRepository : JpaRepository<Course, UUID> {
fun findAllByOrderByCourseNoAsc(): List<Course>
fun findByTitleIgnoreCase(title: String): Course?
}
interface CourseTeacherRepository : JpaRepository<CourseTeacher, UUID> {
fun findByCourseIdOrderByAssignedAtAsc(courseId: UUID): List<CourseTeacher>
fun findByCourseIdAndAccountIdAndRemovedAtIsNull(
courseId: UUID,
accountId: UUID,
): CourseTeacher?
fun countByCourseIdAndRemovedAtIsNull(courseId: UUID): Long
}
interface CourseGroupRepository : JpaRepository<CourseGroup, UUID> {
fun findByCourseIdOrderByAddedAtAsc(courseId: UUID): List<CourseGroup>
fun findByCourseIdAndGroupIdAndRemovedAtIsNull(
courseId: UUID,
groupId: UUID,
): CourseGroup?
fun countByCourseIdAndRemovedAtIsNull(courseId: UUID): Long
}

View File

@@ -7,8 +7,12 @@ import java.util.UUID
interface LabRepository : JpaRepository<Lab, UUID> {
fun findAllByOrderByNumberAsc(): List<Lab>
fun existsByNumberAndSemesterId(
fun findAllByCourseIdOrderByNumberAsc(courseId: UUID): List<Lab>
fun existsByNumberAndCourseId(
number: Int,
semesterId: UUID,
courseId: UUID,
): Boolean
fun countByCourseId(courseId: UUID): Long
}

View File

@@ -0,0 +1,3 @@
-- Один и тот же курс не должен появиться дважды из-за конкурентного bootstrap:
-- уникальный индекс по lower(title) сериализует вставку на уровне БД.
CREATE UNIQUE INDEX courses_title_lower_uidx ON courses (lower(title));

View File

@@ -1,6 +1,6 @@
-- Лабораторные привязываются к курсу. Nullable-фаза: курс C создаёт
-- bootstrap после первого входа админа, затем выполняется post-bootstrap
-- миграция с SET NOT NULL.
-- bootstrap после первого входа админа. `labs.course_id NOT NULL` выставляется
-- в рантайме сервисом CourseBootstrapService после переноса, не отдельным Flyway-файлом.
ALTER TABLE labs ADD COLUMN course_id uuid REFERENCES courses (id) ON DELETE RESTRICT;
ALTER TABLE labs DROP CONSTRAINT IF EXISTS labs_number_semester_unique;

View File

@@ -0,0 +1,300 @@
package ru.ysu.lms.academic.course
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.MethodOrderer
import org.junit.jupiter.api.Order
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestMethodOrder
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.jdbc.core.JdbcTemplate
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.postgresql.PostgreSQLContainer
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.ConflictException
import java.util.UUID
/**
* Интеграционный тест курсов и bootstrap: реальная PostgreSQL (Testcontainers) + Flyway.
*
* Bootstrap проверяется первым, потому что он переносит существующие лабы (course_id IS NULL)
* в курс и завершает nullable-фазу схемы через `ALTER TABLE ... SET NOT NULL`.
*/
@Testcontainers
@SpringBootTest
@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
class CourseServiceIntegrationTest {
companion object {
@Container
@ServiceConnection
@JvmStatic
val postgres = PostgreSQLContainer("postgres:18")
}
@Autowired
private lateinit var courseService: CourseService
@Autowired
private lateinit var bootstrapService: CourseBootstrapService
@Autowired
private lateinit var courseRepository: CourseRepository
@Autowired
private lateinit var courseGroupRepository: CourseGroupRepository
@Autowired
private lateinit var jdbc: JdbcTemplate
@Test
@Order(1)
fun `bootstrap rejects duplicate lab numbers without creating a course`() {
val firstSemester = UUID.randomUUID()
val secondSemester = UUID.randomUUID()
val firstLab = UUID.randomUUID()
val secondLab = UUID.randomUUID()
try {
jdbc.update(
"INSERT INTO academic.semesters (id, title, year, active) VALUES (?, ?, ?, ?)",
firstSemester,
"весна",
2025,
false,
)
jdbc.update(
"INSERT INTO academic.semesters (id, title, year, active) VALUES (?, ?, ?, ?)",
secondSemester,
"осень",
2025,
true,
)
jdbc.update(
"INSERT INTO academic.labs (id, number, title, semester_id) VALUES (?, ?, ?, ?)",
firstLab,
7,
"Дубль из весны",
firstSemester,
)
jdbc.update(
"INSERT INTO academic.labs (id, number, title, semester_id) VALUES (?, ?, ?, ?)",
secondLab,
7,
"Дубль из осени",
secondSemester,
)
assertThatThrownBy {
bootstrapService.bootstrap(CourseBootstrapRequest(), UUID.randomUUID())
}.isInstanceOf(ConflictException::class.java)
.hasMessageContaining("7")
assertThat(courseRepository.count()).isZero()
assertThat(
jdbc.queryForObject("SELECT course_id FROM academic.labs WHERE id = ?", UUID::class.java, firstLab),
).isNull()
assertThat(
jdbc.queryForObject("SELECT course_id FROM academic.labs WHERE id = ?", UUID::class.java, secondLab),
).isNull()
} finally {
jdbc.update("DELETE FROM academic.labs WHERE id IN (?, ?)", firstLab, secondLab)
jdbc.update("DELETE FROM academic.semesters WHERE id IN (?, ?)", firstSemester, secondSemester)
}
}
@Test
@Order(2)
fun `bootstrap creates course, attaches groups and moves labs and chapters`() {
val data = seedLegacyData()
val owner = UUID.randomUUID()
val group1 = UUID.randomUUID()
val group2 = UUID.randomUUID()
val request =
CourseBootstrapRequest(
groupIds = listOf(group1.toString(), group2.toString(), group1.toString()),
)
val first = bootstrapService.bootstrap(request, owner)
assertFirstBootstrap(first)
val courseId = UUID.fromString(first.courseId)
assertLegacyDataAssigned(data, courseId)
assertBootstrapIdempotent(request, first, owner, courseId)
}
private fun seedLegacyData(): LegacyData {
val semesterId = UUID.randomUUID()
jdbc.update(
"INSERT INTO academic.semesters (id, title, year, active) VALUES (?, ?, ?, ?)",
semesterId,
"осень",
2026,
true,
)
val firstLab = UUID.randomUUID()
val secondLab = UUID.randomUUID()
jdbc.update(
"INSERT INTO academic.labs (id, number, title, semester_id) VALUES (?, ?, ?, ?)",
firstLab,
1,
"Лабораторная 1",
semesterId,
)
jdbc.update(
"INSERT INTO academic.labs (id, number, title, semester_id) VALUES (?, ?, ?, ?)",
secondLab,
2,
"Лабораторная 2",
semesterId,
)
val chapterId = UUID.randomUUID()
jdbc.update(
"INSERT INTO academic.chapters (id, title, slug, position, published) VALUES (?, ?, ?, ?, ?)",
chapterId,
"Старая глава",
"staraya-glava",
1,
true,
)
return LegacyData(firstLab, secondLab, chapterId)
}
private fun assertFirstBootstrap(first: CourseBootstrapResult) {
assertThat(first.title).isEqualTo(CourseBootstrapService.DEFAULT_TITLE)
assertThat(first.courseNo).isPositive()
assertThat(first.attachedGroups).isEqualTo(2)
assertThat(first.assignedLabs).isEqualTo(2)
assertThat(first.assignedChapters).isEqualTo(1)
assertThat(first.labsWithoutCourse).isZero()
assertThat(first.chaptersWithoutCourse).isZero()
}
private fun assertLegacyDataAssigned(
data: LegacyData,
courseId: UUID,
) {
listOf(data.firstLab, data.secondLab).forEach { labId ->
assertThat(
jdbc.queryForObject("SELECT course_id FROM academic.labs WHERE id = ?", UUID::class.java, labId),
).isEqualTo(courseId)
}
assertThat(
jdbc.queryForObject("SELECT course_id FROM academic.chapters WHERE id = ?", UUID::class.java, data.chapterId),
).isEqualTo(courseId)
assertThat(
jdbc.queryForObject("SELECT audience FROM academic.chapters WHERE id = ?", String::class.java, data.chapterId),
).isNull()
assertThat(courseGroupRepository.countByCourseIdAndRemovedAtIsNull(courseId)).isEqualTo(2)
assertThat(
jdbc.queryForObject(
"""
SELECT is_nullable FROM information_schema.columns
WHERE table_schema = 'academic' AND table_name = 'labs' AND column_name = 'course_id'
""".trimIndent(),
String::class.java,
),
).isEqualTo("NO")
}
private fun assertBootstrapIdempotent(
request: CourseBootstrapRequest,
first: CourseBootstrapResult,
owner: UUID,
courseId: UUID,
) {
val second = bootstrapService.bootstrap(request, UUID.randomUUID())
assertThat(second.courseId).isEqualTo(first.courseId)
assertThat(second.courseNo).isEqualTo(first.courseNo)
assertThat(second.attachedGroups).isZero()
assertThat(second.assignedLabs).isZero()
assertThat(second.assignedChapters).isZero()
assertThat(second.labsWithoutCourse).isZero()
assertThat(second.chaptersWithoutCourse).isZero()
assertThat(courseRepository.count()).isEqualTo(1)
assertThat(courseRepository.findById(courseId).orElseThrow().ownerAccountId).isEqualTo(owner)
}
private data class LegacyData(
val firstLab: UUID,
val secondLab: UUID,
val chapterId: UUID,
)
@Test
@Order(3)
fun `creates courses with generated unique numbers`() {
val owner = UUID.randomUUID()
val first = courseService.create(CourseCreateRequest("Тестовый курс"), owner)
val second = courseService.create(CourseCreateRequest("Второй тестовый курс"), owner)
assertThat(first.courseNo).isPositive()
assertThat(second.courseNo).isGreaterThan(first.courseNo)
assertThat(first.ownerAccountId).isEqualTo(owner.toString())
assertThat(first.status).isEqualTo("ACTIVE")
assertThat(courseService.detail(UUID.fromString(first.id)).title).isEqualTo("Тестовый курс")
val summaries = courseService.list().associateBy { it.id }
assertThat(summaries[first.id]?.courseNo).isEqualTo(first.courseNo)
assertThat(summaries[first.id]?.teacherCount).isZero()
assertThat(summaries[first.id]?.groupCount).isZero()
assertThat(summaries[first.id]?.labCount).isZero()
}
@Test
@Order(4)
fun `create rejects duplicate title ignoring case`() {
val owner = UUID.randomUUID()
courseService.create(CourseCreateRequest("Дубликат названия"), owner)
assertThatThrownBy {
courseService.create(CourseCreateRequest("ДУБЛИКАТ НАЗВАНИЯ"), UUID.randomUUID())
}.isInstanceOf(ConflictException::class.java)
.hasMessageContaining("уже существует")
}
@Test
@Order(5)
fun `teacher and group assignments are idempotent and keep history`() {
val courseId = UUID.fromString(courseService.create(CourseCreateRequest("История назначений"), UUID.randomUUID()).id)
val teacher = UUID.randomUUID().toString()
val group = UUID.randomUUID().toString()
courseService.assignTeacher(courseId, CourseTeacherRequest(teacher))
courseService.assignTeacher(courseId, CourseTeacherRequest(teacher))
courseService.attachGroup(courseId, CourseGroupRequest(group))
courseService.attachGroup(courseId, CourseGroupRequest(group))
val afterAssign = courseService.detail(courseId)
assertThat(afterAssign.teachers).hasSize(1)
assertThat(afterAssign.teachers.single().removedAt).isNull()
assertThat(afterAssign.groups).hasSize(1)
assertThat(afterAssign.groups.single().removedAt).isNull()
courseService.removeTeacher(courseId, teacher)
courseService.detachGroup(courseId, group)
val afterRemove = courseService.detail(courseId)
assertThat(afterRemove.teachers).hasSize(1)
assertThat(afterRemove.teachers.single().removedAt).isNotNull()
assertThat(afterRemove.groups).hasSize(1)
assertThat(afterRemove.groups.single().removedAt).isNotNull()
val afterReassign = courseService.assignTeacher(courseId, CourseTeacherRequest(teacher))
assertThat(afterReassign.teachers).hasSize(2)
assertThat(afterReassign.teachers.count { it.removedAt == null }).isEqualTo(1)
val summary = courseService.list().single { it.id == courseId.toString() }
assertThat(summary.teacherCount).isEqualTo(1)
assertThat(summary.groupCount).isZero()
}
@Test
@Order(6)
fun `bootstrap rejects malformed group id`() {
assertThatThrownBy {
bootstrapService.bootstrap(CourseBootstrapRequest(groupIds = listOf("not-a-uuid")), UUID.randomUUID())
}.isInstanceOf(BadRequestException::class.java)
}
}

View File

@@ -0,0 +1,165 @@
package ru.ysu.lms.academic.lab
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
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.CourseService
import ru.ysu.lms.academic.course.CourseUpdateRequest
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException
import java.util.UUID
/** Копирование лабораторной в другой курс: новые id, уровни и варианты, без прогресса. */
@Testcontainers
@SpringBootTest
class LabCopyIntegrationTest {
companion object {
@Container
@ServiceConnection
@JvmStatic
val postgres = PostgreSQLContainer("postgres:18")
}
@Autowired
private lateinit var courseService: CourseService
@Autowired
private lateinit var labService: LabService
@Test
fun `copies lab with levels and variants into another course`() {
val owner = UUID.randomUUID()
val sourceCourseId = UUID.fromString(courseService.create(CourseCreateRequest("Курс-источник"), owner).id)
val targetCourseId = UUID.fromString(courseService.create(CourseCreateRequest("Курс-цель"), owner).id)
val source = createSourceLab(sourceCourseId)
val copy = labService.copy(source.labId, LabCopyRequest(courseId = targetCourseId, number = 2, title = "Копия"))
assertThat(copy.id).isNotEqualTo(source.labId.toString())
assertThat(copy.number).isEqualTo(2)
assertThat(copy.title).isEqualTo("Копия")
assertThat(copy.status).isEqualTo("draft")
assertThat(copy.descriptionMd).isEqualTo("Описание")
assertThat(copy.courseId).isEqualTo(targetCourseId.toString())
assertThat(copy.courseTitle).isEqualTo("Курс-цель")
assertThat(copy.courseNo).isNotNull()
assertThat(copy.levels).hasSize(1)
assertThat(copy.levels.single().id).isNotEqualTo(source.levelId)
assertThat(copy.levels.single().variants).hasSize(1)
assertThat(
copy.levels
.single()
.variants
.single()
.id,
).isNotEqualTo(source.variantId)
assertThat(
copy.levels
.single()
.variants
.single()
.statementMd,
).isEqualTo("Условие варианта")
assertThat(labService.list(sourceCourseId).map { it.id }).containsExactly(source.labId.toString())
assertThat(labService.list(targetCourseId).map { it.id }).containsExactly(copy.id)
assertThatThrownBy {
labService.copy(source.labId, LabCopyRequest(courseId = targetCourseId, number = 2))
}.isInstanceOf(ConflictException::class.java)
}
@Test
fun `create requires a course`() {
assertThatThrownBy {
labService.create(LabCreateRequest(number = 5, title = "Без курса"))
}.isInstanceOf(BadRequestException::class.java)
}
@Test
fun `copy rejects the source course as target`() {
val courseId = UUID.fromString(courseService.create(CourseCreateRequest("Тот же курс"), UUID.randomUUID()).id)
val source = createSourceLab(courseId)
assertThatThrownBy {
labService.copy(source.labId, LabCopyRequest(courseId = courseId, number = 2))
}.isInstanceOf(BadRequestException::class.java)
.hasMessageContaining("тот же курс")
}
@Test
fun `copy rejects an archived target course`() {
val owner = UUID.randomUUID()
val sourceCourseId = UUID.fromString(courseService.create(CourseCreateRequest("Источник копии"), owner).id)
val targetCourseId = UUID.fromString(courseService.create(CourseCreateRequest("Архивный курс"), owner).id)
val source = createSourceLab(sourceCourseId)
courseService.update(targetCourseId, CourseUpdateRequest(status = "ARCHIVED"))
assertThatThrownBy {
labService.copy(source.labId, LabCopyRequest(courseId = targetCourseId, number = 2))
}.isInstanceOf(BadRequestException::class.java)
.hasMessageContaining("архив")
}
@Test
fun `create rejects an archived course`() {
val courseId = UUID.fromString(courseService.create(CourseCreateRequest("Архивный курс для лабы"), UUID.randomUUID()).id)
courseService.update(courseId, CourseUpdateRequest(status = "ARCHIVED"))
assertThatThrownBy {
labService.create(LabCreateRequest(courseId = courseId, number = 1, title = "Лаба в архиве"))
}.isInstanceOf(BadRequestException::class.java)
.hasMessageContaining("архив")
}
private fun createSourceLab(courseId: UUID): SourceLab {
val source =
labService.create(
LabCreateRequest(
courseId = courseId,
number = 1,
title = "Исходная лабораторная",
descriptionMd = "Описание",
levels =
listOf(
LevelSaveRequest(code = "basic", title = "Основная группа", description = "Базовый уровень"),
),
),
)
val sourceId = UUID.fromString(source.id)
val levelId = source.levels.single().id
val withVariant =
labService.addVariant(
sourceId,
VariantSaveRequest(
labLevelId = levelId,
number = "1",
title = "Вариант 1",
statementMd = "Условие варианта",
),
)
return SourceLab(
labId = sourceId,
levelId = levelId,
variantId =
withVariant.levels
.single()
.variants
.single()
.id,
)
}
private data class SourceLab(
val labId: UUID,
val levelId: String,
val variantId: String,
)
}