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.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.lab.LabCopyRequest
import ru.ysu.lms.academic.lab.LabCreateRequest import ru.ysu.lms.academic.lab.LabCreateRequest
import ru.ysu.lms.academic.lab.LabDetail import ru.ysu.lms.academic.lab.LabDetail
import ru.ysu.lms.academic.lab.LabImportResult import ru.ysu.lms.academic.lab.LabImportResult
@@ -30,20 +31,30 @@ class LabController(
private val labService: LabService, private val labService: LabService,
) { ) {
@GetMapping @GetMapping
fun list(): List<LabSummary> = labService.list() fun list(
@RequestParam(required = false) courseId: UUID?,
): List<LabSummary> = labService.list(courseId)
@GetMapping("/{id}") @GetMapping("/{id}")
fun detail( fun detail(
@PathVariable id: UUID, @PathVariable id: UUID,
): LabDetail = labService.detail(id) ): 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") @PostMapping("/import")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun importLab( fun importLab(
@RequestParam("file") file: MultipartFile, @RequestParam("file") file: MultipartFile,
@RequestParam(required = false) courseId: UUID?,
): LabImportResult { ): LabImportResult {
if (file.isEmpty) throw BadRequestException("Выберите файл") 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}") @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( class ContentChapter(
@Id var id: UUID = Ids.v7(), @Id var id: UUID = Ids.v7(),
@Column(nullable = false, length = 200) var title: String = "", @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 position: Int = 0,
@Column(nullable = false) var published: Boolean = true, @Column(nullable = false) var published: Boolean = true,
@Column(name = "course_id") var courseId: UUID? = null,
@Column(length = 16) var audience: String? = null,
) )
@Entity @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) @ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "semester_id", nullable = false) @JoinColumn(name = "semester_id", nullable = false)
var semester: Semester? = null, var semester: Semester? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id")
var course: Course? = null,
@Column(name = "description_md", nullable = false) @Column(name = "description_md", nullable = false)
var descriptionMd: String = "", var descriptionMd: String = "",
@Column(name = "deadline_at") @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 package ru.ysu.lms.academic.lab
import java.math.BigDecimal import java.math.BigDecimal
import java.util.UUID
data class LabMeta( data class LabMeta(
val number: Int, val number: Int,
@@ -52,6 +53,9 @@ data class LabSummary(
val defenseAt: String?, val defenseAt: String?,
val pointsMax: BigDecimal, val pointsMax: BigDecimal,
val variantCount: Int, val variantCount: Int,
val courseId: String? = null,
val courseNo: Long? = null,
val courseTitle: String? = null,
) )
data class LabDetail( data class LabDetail(
@@ -64,6 +68,9 @@ data class LabDetail(
val defenseAt: String?, val defenseAt: String?,
val pointsMax: BigDecimal, val pointsMax: BigDecimal,
val levels: List<LabLevelDetail>, val levels: List<LabLevelDetail>,
val courseId: String? = null,
val courseNo: Long? = null,
val courseTitle: String? = null,
) )
data class LabLevelDetail( data class LabLevelDetail(
@@ -114,6 +121,13 @@ data class LabCreateRequest(
val status: String? = null, val status: String? = null,
val descriptionMd: String? = null, val descriptionMd: String? = null,
val levels: List<LevelSaveRequest> = emptyList(), 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( data class LevelRenameRequest(

View File

@@ -2,10 +2,12 @@ package ru.ysu.lms.academic.lab
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.Course
import ru.ysu.lms.academic.domain.Lab import ru.ysu.lms.academic.domain.Lab
import ru.ysu.lms.academic.domain.LabLevel import ru.ysu.lms.academic.domain.LabLevel
import ru.ysu.lms.academic.domain.Semester import ru.ysu.lms.academic.domain.Semester
import ru.ysu.lms.academic.domain.Variant 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.LabLevelRepository
import ru.ysu.lms.academic.repo.LabRepository import ru.ysu.lms.academic.repo.LabRepository
import ru.ysu.lms.academic.repo.SemesterRepository import ru.ysu.lms.academic.repo.SemesterRepository
@@ -20,27 +22,32 @@ import java.time.OffsetDateTime
import java.time.ZoneId import java.time.ZoneId
import java.util.UUID import java.util.UUID
@Suppress("LongParameterList")
@Service @Service
class LabService( class LabService(
private val labs: LabRepository, private val labs: LabRepository,
private val labLevels: LabLevelRepository, private val labLevels: LabLevelRepository,
private val variantsRepo: VariantRepository, private val variantsRepo: VariantRepository,
private val semesters: SemesterRepository, private val semesters: SemesterRepository,
private val courses: CourseRepository,
private val copyService: LabCopyService,
private val parser: LabImportParser, private val parser: LabImportParser,
) { ) {
@Transactional @Transactional
fun importFromFile( fun importFromFile(
filename: String, filename: String,
bytes: ByteArray, bytes: ByteArray,
courseId: UUID? = null,
): LabImportResult { ): LabImportResult {
val bundle = parser.parse(filename, bytes) val bundle = parser.parse(filename, bytes)
val course = courses.resolveCourse(courseId)
val semester = val semester =
semesters.findFirstByActiveTrue() semesters.findFirstByActiveTrue()
?: throw BadRequestException("Нет активного семестра. Создайте семестр.") ?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
if (labs.existsByNumberAndSemesterId(bundle.meta.number, semester.id)) { if (labs.existsByNumberAndCourseId(bundle.meta.number, course.id)) {
throw ConflictException( throw ConflictException(
"Лабораторная №${bundle.meta.number} уже существует. Удалите её перед повторным импортом.", "Лабораторная №${bundle.meta.number} уже существует в этом курсе. Удалите её перед повторным импортом.",
) )
} }
@@ -50,6 +57,7 @@ class LabService(
number = bundle.meta.number, number = bundle.meta.number,
title = bundle.meta.title, title = bundle.meta.title,
semester = semester, semester = semester,
course = course,
descriptionMd = bundle.descriptionMd, descriptionMd = bundle.descriptionMd,
pointsMax = bundle.meta.pointsMax, pointsMax = bundle.meta.pointsMax,
status = bundle.meta.status, status = bundle.meta.status,
@@ -93,8 +101,9 @@ class LabService(
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun list(): List<LabSummary> = fun list(courseId: UUID? = null): List<LabSummary> {
labs.findAllByOrderByNumberAsc().map { lab -> val labList = if (courseId == null) labs.findAllByOrderByNumberAsc() else labs.findAllByCourseIdOrderByNumberAsc(courseId)
return labList.map { lab ->
val variants = variantsRepo.findByLabIdOrderByNumber(lab.id) val variants = variantsRepo.findByLabIdOrderByNumber(lab.id)
LabSummary( LabSummary(
id = lab.id.toString(), id = lab.id.toString(),
@@ -106,8 +115,12 @@ class LabService(
defenseAt = lab.defenseAt?.toString(), defenseAt = lab.defenseAt?.toString(),
pointsMax = lab.pointsMax, pointsMax = lab.pointsMax,
variantCount = variants.size, variantCount = variants.size,
courseId = lab.course?.id?.toString(),
courseNo = lab.course?.courseNo,
courseTitle = lab.course?.title,
) )
} }
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun detail(labId: UUID): LabDetail { 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 @Transactional
fun create(request: LabCreateRequest): LabDetail { fun create(request: LabCreateRequest): LabDetail {
validateCreate(request) validateCreate(request)
val course = courses.requireCourse(request.courseId)
val semester = findOrCreateSemester(request.semesterTitle.orEmpty(), request.semesterYear ?: 0) val semester = findOrCreateSemester(request.semesterTitle.orEmpty(), request.semesterYear ?: 0)
if (labs.existsByNumberAndSemesterId(request.number ?: 0, semester.id)) { if (labs.existsByNumberAndCourseId(request.number ?: 0, course.id)) {
throw ConflictException("Лабораторная №${request.number} уже существует в этом семестре") throw ConflictException("Лабораторная №${request.number} уже существует в этом курсе")
} }
val lab = val lab =
labs.save( labs.save(
@@ -293,6 +310,7 @@ class LabService(
number = request.number ?: 0, number = request.number ?: 0,
title = request.title.orEmpty().trim(), title = request.title.orEmpty().trim(),
semester = semester, semester = semester,
course = course,
descriptionMd = request.descriptionMd.orEmpty(), descriptionMd = request.descriptionMd.orEmpty(),
deadlineAt = parseInstant(request.deadlineAt), deadlineAt = parseInstant(request.deadlineAt),
defenseAt = parseInstant(request.defenseAt), defenseAt = parseInstant(request.defenseAt),
@@ -304,6 +322,13 @@ class LabService(
return detail(lab.id) return detail(lab.id)
} }
/** Копирует лабораторную, её уровни и варианты в другой курс. Прогресс не копируется. */
@Transactional
fun copy(
sourceLabId: UUID,
request: LabCopyRequest,
): LabDetail = detail(copyService.copy(sourceLabId, request))
@Transactional @Transactional
fun renameLevel( fun renameLevel(
labId: UUID, labId: UUID,
@@ -375,8 +400,6 @@ class LabService(
return semesters.save(Semester(title = t, year = y, active = true)) 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? { private fun parseInstant(value: String?): Instant? {
val v = value?.takeIf { it.isNotBlank() } ?: return null val v = value?.takeIf { it.isNotBlank() } ?: return null
return runCatching { Instant.parse(v) } return runCatching { Instant.parse(v) }
@@ -385,3 +408,23 @@ class LabService(
.getOrNull() .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> { interface LabRepository : JpaRepository<Lab, UUID> {
fun findAllByOrderByNumberAsc(): List<Lab> fun findAllByOrderByNumberAsc(): List<Lab>
fun existsByNumberAndSemesterId( fun findAllByCourseIdOrderByNumberAsc(courseId: UUID): List<Lab>
fun existsByNumberAndCourseId(
number: Int, number: Int,
semesterId: UUID, courseId: UUID,
): Boolean ): 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 создаёт -- Лабораторные привязываются к курсу. Nullable-фаза: курс C создаёт
-- bootstrap после первого входа админа, затем выполняется post-bootstrap -- bootstrap после первого входа админа. `labs.course_id NOT NULL` выставляется
-- миграция с SET NOT NULL. -- в рантайме сервисом CourseBootstrapService после переноса, не отдельным Flyway-файлом.
ALTER TABLE labs ADD COLUMN course_id uuid REFERENCES courses (id) ON DELETE RESTRICT; 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; 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,
)
}