From 0c51bf2d1228f65e2e30a946ec1243cef42ae2e3 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Tue, 27 Oct 2026 15:37:46 +0300 Subject: [PATCH] =?UTF-8?q?feat(academic):=20=D0=BA=D1=83=D1=80=D1=81?= =?UTF-8?q?=D1=8B,=20bootstrap=20=D0=B8=20=D0=BF=D1=80=D0=B8=D0=B2=D1=8F?= =?UTF-8?q?=D0=B7=D0=BA=D0=B0=20=D0=BB=D0=B0=D0=B1=D0=BE=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=BD=D1=8B=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../academic/controller/CourseController.kt | 85 +++++ .../lms/academic/controller/LabController.kt | 15 +- .../academic/course/CourseBootstrapService.kt | 148 +++++++++ .../ru/ysu/lms/academic/course/CourseDtos.kt | 69 ++++ .../ysu/lms/academic/course/CourseService.kt | 189 +++++++++++ .../ru/ysu/lms/academic/course/Uuids.kt | 12 + .../ru/ysu/lms/academic/domain/Content.kt | 4 +- .../ru/ysu/lms/academic/domain/Course.kt | 72 +++++ .../kotlin/ru/ysu/lms/academic/domain/Lab.kt | 3 + .../ru/ysu/lms/academic/lab/LabCopyService.kt | 119 +++++++ .../kotlin/ru/ysu/lms/academic/lab/LabDtos.kt | 14 + .../ru/ysu/lms/academic/lab/LabService.kt | 59 +++- .../lms/academic/repo/CourseRepositories.kt | 35 ++ .../ru/ysu/lms/academic/repo/LabRepository.kt | 8 +- .../db/migration/V10__course_title_unique.sql | 3 + .../db/migration/V7__labs_course.sql | 4 +- .../course/CourseServiceIntegrationTest.kt | 300 ++++++++++++++++++ .../academic/lab/LabCopyIntegrationTest.kt | 165 ++++++++++ 18 files changed, 1289 insertions(+), 15 deletions(-) create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseBootstrapService.kt create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseDtos.kt create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseService.kt create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/Uuids.kt create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Course.kt create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabCopyService.kt create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/CourseRepositories.kt create mode 100644 services/academic-service/src/main/resources/db/migration/V10__course_title_unique.sql create mode 100644 services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseServiceIntegrationTest.kt create mode 100644 services/academic-service/src/test/kotlin/ru/ysu/lms/academic/lab/LabCopyIntegrationTest.kt diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt new file mode 100644 index 0000000..2373cc4 --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt @@ -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 = 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 ?: "", "идентификатор пользователя в токене") +} diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt index 31aa8ef..7d52947 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt @@ -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 = labService.list() + fun list( + @RequestParam(required = false) courseId: UUID?, + ): List = 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}") diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseBootstrapService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseBootstrapService.kt new file mode 100644 index 0000000..58e9eaf --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseBootstrapService.kt @@ -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, + ): 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 = + 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" + } +} diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseDtos.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseDtos.kt new file mode 100644 index 0000000..36d1881 --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseDtos.kt @@ -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, + val groups: List, +) + +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 = 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, +) diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseService.kt new file mode 100644 index 0000000..20aaef4 --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseService.kt @@ -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 = + 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 = "Новый курс" + } +} diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/Uuids.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/Uuids.kt new file mode 100644 index 0000000..846651a --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/Uuids.kt @@ -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") } diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Content.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Content.kt index a06ed72..6c7393f 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Content.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Content.kt @@ -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 diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Course.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Course.kt new file mode 100644 index 0000000..e4e58f6 --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Course.kt @@ -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, +) diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Lab.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Lab.kt index 4e99809..2695c60 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Lab.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Lab.kt @@ -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") diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabCopyService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabCopyService.kt new file mode 100644 index 0000000..8ab6875 --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabCopyService.kt @@ -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, + ), + ) + } + } +} diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabDtos.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabDtos.kt index 84c8c18..ba93993 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabDtos.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabDtos.kt @@ -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, + 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 = emptyList(), + val courseId: UUID? = null, +) + +data class LabCopyRequest( + val courseId: UUID, + val number: Int? = null, + val title: String? = null, ) data class LevelRenameRequest( diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt index a589bce..4fee18a 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt @@ -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 = - labs.findAllByOrderByNumberAsc().map { lab -> + fun list(courseId: UUID? = null): List { + 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 diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/CourseRepositories.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/CourseRepositories.kt new file mode 100644 index 0000000..7bdf9d8 --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/CourseRepositories.kt @@ -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 { + fun findAllByOrderByCourseNoAsc(): List + + fun findByTitleIgnoreCase(title: String): Course? +} + +interface CourseTeacherRepository : JpaRepository { + fun findByCourseIdOrderByAssignedAtAsc(courseId: UUID): List + + fun findByCourseIdAndAccountIdAndRemovedAtIsNull( + courseId: UUID, + accountId: UUID, + ): CourseTeacher? + + fun countByCourseIdAndRemovedAtIsNull(courseId: UUID): Long +} + +interface CourseGroupRepository : JpaRepository { + fun findByCourseIdOrderByAddedAtAsc(courseId: UUID): List + + fun findByCourseIdAndGroupIdAndRemovedAtIsNull( + courseId: UUID, + groupId: UUID, + ): CourseGroup? + + fun countByCourseIdAndRemovedAtIsNull(courseId: UUID): Long +} diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/LabRepository.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/LabRepository.kt index 65f2fdf..3579cd4 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/LabRepository.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/LabRepository.kt @@ -7,8 +7,12 @@ import java.util.UUID interface LabRepository : JpaRepository { fun findAllByOrderByNumberAsc(): List - fun existsByNumberAndSemesterId( + fun findAllByCourseIdOrderByNumberAsc(courseId: UUID): List + + fun existsByNumberAndCourseId( number: Int, - semesterId: UUID, + courseId: UUID, ): Boolean + + fun countByCourseId(courseId: UUID): Long } diff --git a/services/academic-service/src/main/resources/db/migration/V10__course_title_unique.sql b/services/academic-service/src/main/resources/db/migration/V10__course_title_unique.sql new file mode 100644 index 0000000..88e0c05 --- /dev/null +++ b/services/academic-service/src/main/resources/db/migration/V10__course_title_unique.sql @@ -0,0 +1,3 @@ +-- Один и тот же курс не должен появиться дважды из-за конкурентного bootstrap: +-- уникальный индекс по lower(title) сериализует вставку на уровне БД. +CREATE UNIQUE INDEX courses_title_lower_uidx ON courses (lower(title)); diff --git a/services/academic-service/src/main/resources/db/migration/V7__labs_course.sql b/services/academic-service/src/main/resources/db/migration/V7__labs_course.sql index 17a582f..2976171 100644 --- a/services/academic-service/src/main/resources/db/migration/V7__labs_course.sql +++ b/services/academic-service/src/main/resources/db/migration/V7__labs_course.sql @@ -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; diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseServiceIntegrationTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseServiceIntegrationTest.kt new file mode 100644 index 0000000..237b4f1 --- /dev/null +++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseServiceIntegrationTest.kt @@ -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) + } +} diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/lab/LabCopyIntegrationTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/lab/LabCopyIntegrationTest.kt new file mode 100644 index 0000000..7846599 --- /dev/null +++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/lab/LabCopyIntegrationTest.kt @@ -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, + ) +}