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