feat(academic): курсы, bootstrap и привязка лабораторных
This commit is contained in:
@@ -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 ?: "", "идентификатор пользователя в токене")
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import ru.ysu.lms.academic.lab.LabCopyRequest
|
||||
import ru.ysu.lms.academic.lab.LabCreateRequest
|
||||
import ru.ysu.lms.academic.lab.LabDetail
|
||||
import ru.ysu.lms.academic.lab.LabImportResult
|
||||
@@ -30,20 +31,30 @@ class LabController(
|
||||
private val labService: LabService,
|
||||
) {
|
||||
@GetMapping
|
||||
fun list(): List<LabSummary> = labService.list()
|
||||
fun list(
|
||||
@RequestParam(required = false) courseId: UUID?,
|
||||
): List<LabSummary> = labService.list(courseId)
|
||||
|
||||
@GetMapping("/{id}")
|
||||
fun detail(
|
||||
@PathVariable id: UUID,
|
||||
): LabDetail = labService.detail(id)
|
||||
|
||||
@PostMapping("/{id}/copy")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun copy(
|
||||
@PathVariable id: UUID,
|
||||
@Valid @RequestBody body: LabCopyRequest,
|
||||
): LabDetail = labService.copy(id, body)
|
||||
|
||||
@PostMapping("/import")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun importLab(
|
||||
@RequestParam("file") file: MultipartFile,
|
||||
@RequestParam(required = false) courseId: UUID?,
|
||||
): LabImportResult {
|
||||
if (file.isEmpty) throw BadRequestException("Выберите файл")
|
||||
return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes)
|
||||
return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes, courseId)
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 = "Новый курс"
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package ru.ysu.lms.academic.lab
|
||||
|
||||
import java.math.BigDecimal
|
||||
import java.util.UUID
|
||||
|
||||
data class LabMeta(
|
||||
val number: Int,
|
||||
@@ -52,6 +53,9 @@ data class LabSummary(
|
||||
val defenseAt: String?,
|
||||
val pointsMax: BigDecimal,
|
||||
val variantCount: Int,
|
||||
val courseId: String? = null,
|
||||
val courseNo: Long? = null,
|
||||
val courseTitle: String? = null,
|
||||
)
|
||||
|
||||
data class LabDetail(
|
||||
@@ -64,6 +68,9 @@ data class LabDetail(
|
||||
val defenseAt: String?,
|
||||
val pointsMax: BigDecimal,
|
||||
val levels: List<LabLevelDetail>,
|
||||
val courseId: String? = null,
|
||||
val courseNo: Long? = null,
|
||||
val courseTitle: String? = null,
|
||||
)
|
||||
|
||||
data class LabLevelDetail(
|
||||
@@ -114,6 +121,13 @@ data class LabCreateRequest(
|
||||
val status: String? = null,
|
||||
val descriptionMd: String? = null,
|
||||
val levels: List<LevelSaveRequest> = emptyList(),
|
||||
val courseId: UUID? = null,
|
||||
)
|
||||
|
||||
data class LabCopyRequest(
|
||||
val courseId: UUID,
|
||||
val number: Int? = null,
|
||||
val title: String? = null,
|
||||
)
|
||||
|
||||
data class LevelRenameRequest(
|
||||
|
||||
@@ -2,10 +2,12 @@ package ru.ysu.lms.academic.lab
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.academic.domain.Course
|
||||
import ru.ysu.lms.academic.domain.Lab
|
||||
import ru.ysu.lms.academic.domain.LabLevel
|
||||
import ru.ysu.lms.academic.domain.Semester
|
||||
import ru.ysu.lms.academic.domain.Variant
|
||||
import ru.ysu.lms.academic.repo.CourseRepository
|
||||
import ru.ysu.lms.academic.repo.LabLevelRepository
|
||||
import ru.ysu.lms.academic.repo.LabRepository
|
||||
import ru.ysu.lms.academic.repo.SemesterRepository
|
||||
@@ -20,27 +22,32 @@ import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Service
|
||||
class LabService(
|
||||
private val labs: LabRepository,
|
||||
private val labLevels: LabLevelRepository,
|
||||
private val variantsRepo: VariantRepository,
|
||||
private val semesters: SemesterRepository,
|
||||
private val courses: CourseRepository,
|
||||
private val copyService: LabCopyService,
|
||||
private val parser: LabImportParser,
|
||||
) {
|
||||
@Transactional
|
||||
fun importFromFile(
|
||||
filename: String,
|
||||
bytes: ByteArray,
|
||||
courseId: UUID? = null,
|
||||
): LabImportResult {
|
||||
val bundle = parser.parse(filename, bytes)
|
||||
val course = courses.resolveCourse(courseId)
|
||||
val semester =
|
||||
semesters.findFirstByActiveTrue()
|
||||
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
|
||||
|
||||
if (labs.existsByNumberAndSemesterId(bundle.meta.number, semester.id)) {
|
||||
if (labs.existsByNumberAndCourseId(bundle.meta.number, course.id)) {
|
||||
throw ConflictException(
|
||||
"Лабораторная №${bundle.meta.number} уже существует. Удалите её перед повторным импортом.",
|
||||
"Лабораторная №${bundle.meta.number} уже существует в этом курсе. Удалите её перед повторным импортом.",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,6 +57,7 @@ class LabService(
|
||||
number = bundle.meta.number,
|
||||
title = bundle.meta.title,
|
||||
semester = semester,
|
||||
course = course,
|
||||
descriptionMd = bundle.descriptionMd,
|
||||
pointsMax = bundle.meta.pointsMax,
|
||||
status = bundle.meta.status,
|
||||
@@ -93,8 +101,9 @@ class LabService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun list(): List<LabSummary> =
|
||||
labs.findAllByOrderByNumberAsc().map { lab ->
|
||||
fun list(courseId: UUID? = null): List<LabSummary> {
|
||||
val labList = if (courseId == null) labs.findAllByOrderByNumberAsc() else labs.findAllByCourseIdOrderByNumberAsc(courseId)
|
||||
return labList.map { lab ->
|
||||
val variants = variantsRepo.findByLabIdOrderByNumber(lab.id)
|
||||
LabSummary(
|
||||
id = lab.id.toString(),
|
||||
@@ -106,8 +115,12 @@ class LabService(
|
||||
defenseAt = lab.defenseAt?.toString(),
|
||||
pointsMax = lab.pointsMax,
|
||||
variantCount = variants.size,
|
||||
courseId = lab.course?.id?.toString(),
|
||||
courseNo = lab.course?.courseNo,
|
||||
courseTitle = lab.course?.title,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun detail(labId: UUID): LabDetail {
|
||||
@@ -145,6 +158,9 @@ class LabService(
|
||||
},
|
||||
)
|
||||
},
|
||||
courseId = lab.course?.id?.toString(),
|
||||
courseNo = lab.course?.courseNo,
|
||||
courseTitle = lab.course?.title,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -283,9 +299,10 @@ class LabService(
|
||||
@Transactional
|
||||
fun create(request: LabCreateRequest): LabDetail {
|
||||
validateCreate(request)
|
||||
val course = courses.requireCourse(request.courseId)
|
||||
val semester = findOrCreateSemester(request.semesterTitle.orEmpty(), request.semesterYear ?: 0)
|
||||
if (labs.existsByNumberAndSemesterId(request.number ?: 0, semester.id)) {
|
||||
throw ConflictException("Лабораторная №${request.number} уже существует в этом семестре")
|
||||
if (labs.existsByNumberAndCourseId(request.number ?: 0, course.id)) {
|
||||
throw ConflictException("Лабораторная №${request.number} уже существует в этом курсе")
|
||||
}
|
||||
val lab =
|
||||
labs.save(
|
||||
@@ -293,6 +310,7 @@ class LabService(
|
||||
number = request.number ?: 0,
|
||||
title = request.title.orEmpty().trim(),
|
||||
semester = semester,
|
||||
course = course,
|
||||
descriptionMd = request.descriptionMd.orEmpty(),
|
||||
deadlineAt = parseInstant(request.deadlineAt),
|
||||
defenseAt = parseInstant(request.defenseAt),
|
||||
@@ -304,6 +322,13 @@ class LabService(
|
||||
return detail(lab.id)
|
||||
}
|
||||
|
||||
/** Копирует лабораторную, её уровни и варианты в другой курс. Прогресс не копируется. */
|
||||
@Transactional
|
||||
fun copy(
|
||||
sourceLabId: UUID,
|
||||
request: LabCopyRequest,
|
||||
): LabDetail = detail(copyService.copy(sourceLabId, request))
|
||||
|
||||
@Transactional
|
||||
fun renameLevel(
|
||||
labId: UUID,
|
||||
@@ -375,8 +400,6 @@ class LabService(
|
||||
return semesters.save(Semester(title = t, year = y, active = true))
|
||||
}
|
||||
|
||||
private fun naturalNumber(value: String): Int = Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
|
||||
|
||||
private fun parseInstant(value: String?): Instant? {
|
||||
val v = value?.takeIf { it.isNotBlank() } ?: return null
|
||||
return runCatching { Instant.parse(v) }
|
||||
@@ -385,3 +408,23 @@ class LabService(
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
/** Явно заданный курс или единственный существующий (для импорта без courseId). */
|
||||
private fun CourseRepository.resolveCourse(courseId: UUID?): Course {
|
||||
if (courseId != null) return requireCourse(courseId)
|
||||
val all = findAllByOrderByCourseNoAsc()
|
||||
if (all.size == 1) return all.single().rejectArchived()
|
||||
throw BadRequestException("Укажите курс: в системе должен быть ровно один курс для неявного выбора")
|
||||
}
|
||||
|
||||
private fun CourseRepository.requireCourse(courseId: UUID?): Course {
|
||||
if (courseId == null) throw BadRequestException("Укажите курс")
|
||||
return findById(courseId).orElseThrow { NotFoundException("Курс не найден") }.rejectArchived()
|
||||
}
|
||||
|
||||
private fun Course.rejectArchived(): Course {
|
||||
if (status == Course.STATUS_ARCHIVED) throw BadRequestException("Курс архивирован")
|
||||
return this
|
||||
}
|
||||
|
||||
private fun naturalNumber(value: String): Int = Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -7,8 +7,12 @@ import java.util.UUID
|
||||
interface LabRepository : JpaRepository<Lab, UUID> {
|
||||
fun findAllByOrderByNumberAsc(): List<Lab>
|
||||
|
||||
fun existsByNumberAndSemesterId(
|
||||
fun findAllByCourseIdOrderByNumberAsc(courseId: UUID): List<Lab>
|
||||
|
||||
fun existsByNumberAndCourseId(
|
||||
number: Int,
|
||||
semesterId: UUID,
|
||||
courseId: UUID,
|
||||
): Boolean
|
||||
|
||||
fun countByCourseId(courseId: UUID): Long
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Один и тот же курс не должен появиться дважды из-за конкурентного bootstrap:
|
||||
-- уникальный индекс по lower(title) сериализует вставку на уровне БД.
|
||||
CREATE UNIQUE INDEX courses_title_lower_uidx ON courses (lower(title));
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user