feat(identity): повышение курса и жизненный цикл студентов

This commit is contained in:
2026-10-27 18:41:56 +03:00
parent 753cb5ba0a
commit e139a585b4
34 changed files with 2689 additions and 4 deletions

View File

@@ -12,7 +12,7 @@ spring:
- id: identity
uri: ${LMS_IDENTITY_URI:http://localhost:8081}
predicates:
- Path=/api/v1/auth/**,/api/v1/accounts/**,/api/v1/groups/**,/api/v1/students/**,/api/v1/teachers/**
- Path=/api/v1/auth/**,/api/v1/accounts/**,/api/v1/groups/**,/api/v1/students/**,/api/v1/teachers/**,/api/v1/promotion/**
- id: progress-profile
uri: ${LMS_PROGRESS_URI:http://localhost:8083}
predicates:

View File

@@ -2,10 +2,12 @@ package ru.ysu.lms.identity
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling
import ru.ysu.lms.security.LmsSecurityAutoConfiguration
// The identity service manages its own JWT keys at runtime, so the generic
// auto-configuration from the shared library is excluded.
@EnableScheduling
@SpringBootApplication(exclude = [LmsSecurityAutoConfiguration::class])
class IdentityServiceApplication

View File

@@ -14,18 +14,22 @@ import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.AccountStatus
import ru.ysu.lms.identity.domain.GroupStatus
import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.AccountRepository
import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.JwtProperties
import ru.ysu.lms.security.LmsRole
import java.time.Instant
import java.util.UUID
@Suppress("LongParameterList")
@Service
class AuthService(
private val accounts: AccountRepository,
private val students: StudentRepository,
private val enrollments: EnrollmentRepository,
private val passwordEncoder: PasswordEncoder,
private val jwtEncoder: JwtEncoder,
private val jwtProperties: JwtProperties,
@@ -77,6 +81,14 @@ class AuthService(
if (status in setOf(StudentStatus.EXPELLED, StudentStatus.GRADUATED, StudentStatus.ARCHIVED)) {
throw UnauthorizedException("Доступ к системе закрыт")
}
ensureGroupOpen(account)
}
private fun ensureGroupOpen(account: Account) {
val groupStatus = enrollments.findByStudentAccountId(account.id)?.group?.status
if (groupStatus in setOf(GroupStatus.GRADUATED, GroupStatus.ARCHIVED)) {
throw UnauthorizedException("Группа закрыта")
}
}
@Transactional(readOnly = true)

View File

@@ -0,0 +1,7 @@
package ru.ysu.lms.identity.domain
enum class PromotionAction {
PROMOTE,
GRADUATE,
ACADEMIC,
}

View File

@@ -0,0 +1,6 @@
package ru.ysu.lms.identity.domain
enum class PromotionEntityType {
GROUP,
STUDENT,
}

View File

@@ -0,0 +1,9 @@
package ru.ysu.lms.identity.domain
enum class PromotionItemStatus {
PLANNED,
APPLIED,
SKIPPED,
ERROR,
ROLLED_BACK,
}

View File

@@ -0,0 +1,6 @@
package ru.ysu.lms.identity.domain
enum class PromotionMode {
DRY_RUN,
APPLY,
}

View File

@@ -0,0 +1,37 @@
package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID
/**
* Шапка запуска повышения. JSONB-поле `result` намеренно не отображается
* Hibernate: оно читается/пишется через [ru.ysu.lms.identity.promotion.PromotionJsonStore],
* потому что Hibernate 7 в этом проекте использует Jackson 2, а приложение — Jackson 3.
*/
@Entity
@Table(name = "promotion_runs")
class PromotionRun(
@Id
var id: UUID = Ids.v7(),
@Column(nullable = false)
var year: Int = 0,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
var mode: PromotionMode = PromotionMode.DRY_RUN,
@Column(name = "actor_account_id")
var actorAccountId: UUID? = null,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
var status: PromotionRunStatus = PromotionRunStatus.PLANNED,
@Column(name = "started_at", nullable = false)
var startedAt: Instant = Instant.now(),
@Column(name = "finished_at")
var finishedAt: Instant? = null,
)

View File

@@ -0,0 +1,36 @@
package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID
/** По-элементный план/результат повышения. before/after хранятся в JSONB через JDBC. */
@Entity
@Table(name = "promotion_run_items")
class PromotionRunItem(
@Id
var id: UUID = Ids.v7(),
@Column(name = "run_id", nullable = false)
var runId: UUID = UUID(0, 0),
@Enumerated(EnumType.STRING)
@Column(name = "entity_type", nullable = false, length = 16)
var entityType: PromotionEntityType = PromotionEntityType.GROUP,
@Column(name = "entity_id", nullable = false)
var entityId: UUID = UUID(0, 0),
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
var action: PromotionAction = PromotionAction.PROMOTE,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
var status: PromotionItemStatus = PromotionItemStatus.PLANNED,
@Column(nullable = false)
var error: String = "",
@Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(),
)

View File

@@ -0,0 +1,9 @@
package ru.ysu.lms.identity.domain
enum class PromotionRunStatus {
PLANNED,
RUNNING,
COMPLETED,
FAILED,
ROLLED_BACK,
}

View File

@@ -0,0 +1,296 @@
package ru.ysu.lms.identity.promotion
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.identity.domain.Enrollment
import ru.ysu.lms.identity.domain.GroupEntity
import ru.ysu.lms.identity.domain.GroupStatus
import ru.ysu.lms.identity.domain.MembershipAction
import ru.ysu.lms.identity.domain.MembershipHistory
import ru.ysu.lms.identity.domain.PromotionAction
import ru.ysu.lms.identity.domain.PromotionEntityType
import ru.ysu.lms.identity.domain.PromotionItemStatus
import ru.ysu.lms.identity.domain.PromotionMode
import ru.ysu.lms.identity.domain.PromotionRun
import ru.ysu.lms.identity.domain.PromotionRunItem
import ru.ysu.lms.identity.domain.PromotionRunStatus
import ru.ysu.lms.identity.domain.Student
import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.MembershipHistoryRepository
import ru.ysu.lms.identity.repo.PromotionRunItemRepository
import ru.ysu.lms.identity.repo.PromotionRunRepository
import ru.ysu.lms.identity.repo.StudentRepository
import java.time.Clock
import java.time.Instant
import java.util.UUID
private data class OrderedItem(
val item: PromotionRunItem,
val state: PromotionItemState,
)
@Suppress("LongParameterList")
@Component
class PromotionApplier(
private val runs: PromotionRunRepository,
private val items: PromotionRunItemRepository,
private val json: PromotionJsonStore,
private val groups: GroupRepository,
private val students: StudentRepository,
private val enrollments: EnrollmentRepository,
private val history: MembershipHistoryRepository,
private val verifier: PromotionItemVerifier,
private val runLock: PromotionRunLock,
private val clock: Clock,
) {
@Transactional
fun apply(
runId: UUID,
actorAccountId: UUID?,
) {
runLock.acquire(runId)
val run = loadRun(runId)
if (run.status == PromotionRunStatus.COMPLETED) return
checkCanApply(run)
val states = json.readItemStates(runId)
val runItems = items.findByRunIdOrderByIdAsc(runId)
val conflicts = runItems.mapNotNull { verifier.verifyBefore(it, states[it.id]) }
if (conflicts.isNotEmpty()) {
promotionConflict("Данные изменились после dry-run: ${conflicts.joinToString("; ")}")
}
markRunning(run, actorAccountId)
val now = Instant.now(clock)
val ordered = orderForApply(runItems, states)
ordered.forEach { orderedItem -> applyItem(orderedItem, run, actorAccountId) }
run.status = PromotionRunStatus.COMPLETED
run.finishedAt = now
runs.saveAndFlush(run)
updateResult(run, mapOf("status" to "COMPLETED", "appliedAt" to now.toString()))
}
private fun checkCanApply(run: PromotionRun) {
if (run.status != PromotionRunStatus.PLANNED || run.mode != PromotionMode.DRY_RUN) {
promotionConflict("Применить можно только запланированный dry-run (статус ${run.status})")
}
if (runs.existsByYearAndModeAndStatusIn(run.year, PromotionMode.APPLY, ACTIVE_APPLY_STATUSES)) {
promotionConflict("Повышение за ${run.year} уже применено или выполняется")
}
}
private fun markRunning(
run: PromotionRun,
actorAccountId: UUID?,
) {
run.mode = PromotionMode.APPLY
run.status = PromotionRunStatus.RUNNING
run.actorAccountId = actorAccountId ?: run.actorAccountId
run.startedAt = Instant.now(clock)
run.finishedAt = null
try {
runs.saveAndFlush(run)
} catch (error: DataIntegrityViolationException) {
promotionConflict("Повышение за ${run.year} уже применено или выполняется: ${error.mostSpecificCause.message}")
}
}
private fun applyItem(
ordered: OrderedItem,
run: PromotionRun,
actorAccountId: UUID?,
) {
when (ordered.item.entityType) {
PromotionEntityType.GROUP -> applyGroup(ordered, run, actorAccountId)
PromotionEntityType.STUDENT -> applyStudent(ordered, run, actorAccountId)
}
ordered.item.status = PromotionItemStatus.APPLIED
ordered.item.error = ""
items.save(ordered.item)
}
private fun applyGroup(
ordered: OrderedItem,
run: PromotionRun,
actorAccountId: UUID?,
) {
val group = loadGroup(ordered.item.entityId)
when (ordered.item.action) {
PromotionAction.PROMOTE -> promoteGroup(group, ordered.state.after)
PromotionAction.GRADUATE -> {
group.status = GroupStatus.GRADUATED
groups.save(group)
}
PromotionAction.ACADEMIC -> throw ConflictException("Некорректный элемент плана для группы")
}
if (ordered.item.action == PromotionAction.PROMOTE) {
activeEnrollments(group.id).forEach { enrollment ->
recordHistory(enrollment, group, group, MembershipAction.PROMOTE, run, actorAccountId)
}
}
}
private fun promoteGroup(
group: GroupEntity,
after: Map<String, Any?>,
) {
val course = after.courseValue() ?: throw ConflictException("В плане группы ${group.name} нет курса")
val name = after.nameValue() ?: throw ConflictException("В плане группы ${group.name} нет имени")
group.course = course.toShort()
group.name = name
groups.save(group)
}
private fun applyStudent(
ordered: OrderedItem,
run: PromotionRun,
actorAccountId: UUID?,
) {
when (ordered.item.action) {
PromotionAction.GRADUATE -> graduateStudent(ordered, run, actorAccountId)
PromotionAction.ACADEMIC -> moveAcademicStudent(ordered, run, actorAccountId)
PromotionAction.PROMOTE -> throw ConflictException("Некорректный элемент плана для студента")
}
}
private fun graduateStudent(
ordered: OrderedItem,
run: PromotionRun,
actorAccountId: UUID?,
) {
val student = loadStudent(ordered.item.entityId)
val groupId = ordered.state.before.uuidValue("groupId")
student.status = StudentStatus.GRADUATED
students.save(student)
history.save(
MembershipHistory(
studentId = student.accountId!!,
fromGroupId = groupId,
toGroupId = null,
action = MembershipAction.GRADUATE,
reason = "Повышение ${run.year}",
actorAccountId = actorAccountId,
promotionRunId = run.id,
),
)
}
private fun moveAcademicStudent(
ordered: OrderedItem,
run: PromotionRun,
actorAccountId: UUID?,
) {
val student = loadStudent(ordered.item.entityId)
val enrollment =
enrollments.findByStudentAccountId(student.accountId!!)
?: throw ConflictException("У студента ${student.fullName} нет зачисления")
val target = ensureTargetGroup(ordered.state.after)
val fromGroupId = ordered.state.before.uuidValue("groupId")
enrollment.group = target
enrollments.save(enrollment)
student.enrollmentYear =
ordered.state.after
.intValue("enrollmentYear")
?.toShort()
student.status = StudentStatus.ACADEMIC_LEAVE
students.save(student)
history.save(
MembershipHistory(
studentId = student.accountId!!,
fromGroupId = fromGroupId,
toGroupId = target.id,
action = MembershipAction.ACADEMIC,
reason = "Повышение ${run.year}",
actorAccountId = actorAccountId,
promotionRunId = run.id,
),
)
}
private fun ensureTargetGroup(after: Map<String, Any?>): GroupEntity {
val target =
after.mapValue("targetGroup")
?: promotionConflict("В плане академ-перевода нет целевой группы")
val id =
target.uuidValue("id")
?: promotionConflict("В плане академ-перевода нет id целевой группы")
groups.findById(id).orElse(null)?.let { return it }
if (target["created"] != true) {
promotionConflict("Целевая группа ${target.nameValue()} не найдена")
}
return groups.save(
GroupEntity(
id = id,
name = target.nameValue() ?: promotionConflict("Нет имени целевой группы"),
stream = target.textValue("stream"),
course = target.intValue("course")?.toShort(),
number = target.intValue("number")?.toShort(),
degree = target.textValue("degree"),
enrollmentYear = target.intValue("enrollmentYear")?.toShort(),
status = GroupStatus.ACTIVE,
),
)
}
private fun orderForApply(
runItems: List<PromotionRunItem>,
states: Map<UUID, PromotionItemState>,
): List<OrderedItem> =
runItems
.mapNotNull { item -> states[item.id]?.let { OrderedItem(item, it) } }
.sortedWith(
compareBy(
{ if (it.item.entityType == PromotionEntityType.GROUP) 0 else 1 },
{ if (it.item.entityType == PromotionEntityType.GROUP) -(it.state.before.courseValue() ?: 0) else 0 },
{ it.item.action.ordinal },
{ it.item.entityId.toString() },
),
)
private fun activeEnrollments(groupId: UUID): List<Enrollment> =
enrollments.findAllByGroupId(groupId).filter { it.student?.status == StudentStatus.ACTIVE }
private fun recordHistory(
enrollment: Enrollment,
from: GroupEntity,
to: GroupEntity,
action: MembershipAction,
run: PromotionRun,
actorAccountId: UUID?,
) {
history.save(
MembershipHistory(
studentId = enrollment.student!!.accountId!!,
fromGroupId = from.id,
toGroupId = to.id,
action = action,
reason = "Повышение ${run.year}",
actorAccountId = actorAccountId,
promotionRunId = run.id,
),
)
}
private fun loadRun(runId: UUID): PromotionRun = runs.findById(runId).orElseThrow { NotFoundException("Запуск повышения не найден") }
private fun loadGroup(groupId: UUID): GroupEntity =
groups.findById(groupId).orElseThrow { NotFoundException("Группа $groupId не найдена") }
private fun loadStudent(studentId: UUID): Student =
students.findById(studentId).orElseThrow { NotFoundException("Студент $studentId не найден") }
private fun updateResult(
run: PromotionRun,
extra: Map<String, Any?>,
) {
val current = json.readRunResult(run.id) ?: emptyMap()
json.writeRunResult(run.id, current + extra)
}
private companion object {
val ACTIVE_APPLY_STATUSES = listOf(PromotionRunStatus.RUNNING, PromotionRunStatus.COMPLETED)
}
}

View File

@@ -0,0 +1,12 @@
package ru.ysu.lms.identity.promotion
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.Clock
import java.time.ZoneId
@Configuration
class PromotionClockConfig {
@Bean
fun promotionClock(): Clock = Clock.system(ZoneId.of("Europe/Moscow"))
}

View File

@@ -0,0 +1,51 @@
package ru.ysu.lms.identity.promotion
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.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/api/v1/promotion")
@PreAuthorize("hasRole('ADMIN')")
class PromotionController(
private val promotionService: PromotionService,
) {
@GetMapping
fun list(): List<PromotionRunSummary> = promotionService.list()
@GetMapping("/{id}")
fun detail(
@PathVariable id: UUID,
): PromotionRunDetail = promotionService.report(id)
@PostMapping("/dry-run")
fun dryRun(
@RequestBody(required = false) body: DryRunRequest?,
@AuthenticationPrincipal jwt: Jwt,
): PromotionRunDetail = promotionService.dryRun(body?.year, UUID.fromString(jwt.subject))
@PostMapping("/{id}/apply")
fun apply(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): PromotionRunDetail = promotionService.apply(id, UUID.fromString(jwt.subject))
@PostMapping("/{id}/cancel")
fun cancel(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): PromotionRunDetail = promotionService.cancel(id, UUID.fromString(jwt.subject))
@PostMapping("/{id}/rollback")
fun rollback(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): PromotionRunDetail = promotionService.rollback(id, UUID.fromString(jwt.subject))
}

View File

@@ -0,0 +1,42 @@
package ru.ysu.lms.identity.promotion
import java.time.Instant
data class PromotionRunSummary(
val id: String,
val year: Int,
val mode: String,
val status: String,
val actorAccountId: String?,
val startedAt: Instant,
val finishedAt: Instant?,
val result: Map<String, Any?>?,
)
data class PromotionItemView(
val id: String,
val entityType: String,
val entityId: String,
val action: String,
val status: String,
val error: String,
val createdAt: Instant,
val before: Map<String, Any?>,
val after: Map<String, Any?>,
)
data class PromotionRunDetail(
val id: String,
val year: Int,
val mode: String,
val status: String,
val actorAccountId: String?,
val startedAt: Instant,
val finishedAt: Instant?,
val result: Map<String, Any?>?,
val items: List<PromotionItemView>,
)
data class DryRunRequest(
val year: Int? = null,
)

View File

@@ -0,0 +1,135 @@
package ru.ysu.lms.identity.promotion
import org.springframework.stereotype.Component
import ru.ysu.lms.identity.domain.GroupEntity
import ru.ysu.lms.identity.domain.PromotionAction
import ru.ysu.lms.identity.domain.PromotionEntityType
import ru.ysu.lms.identity.domain.PromotionRunItem
import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.StudentRepository
/** Сверка текущего состояния с before/after журнала повышения. */
@Component
class PromotionItemVerifier(
private val groups: GroupRepository,
private val students: StudentRepository,
private val enrollments: EnrollmentRepository,
) {
fun verifyBefore(
item: PromotionRunItem,
state: PromotionItemState?,
): String? {
if (state == null) return "нет before/after для элемента ${item.id}"
return when (item.entityType) {
PromotionEntityType.GROUP -> verifyGroupBefore(item, state.before)
PromotionEntityType.STUDENT -> verifyStudentBefore(item, state.before, state.after)
}
}
fun verifyAfter(
item: PromotionRunItem,
state: PromotionItemState?,
): String? {
if (state == null) return "нет before/after для элемента ${item.id}"
return when (item.entityType) {
PromotionEntityType.GROUP -> verifyGroupAfter(item, state.after)
PromotionEntityType.STUDENT -> verifyStudentAfter(item, state.after)
}
}
private fun verifyGroupBefore(
item: PromotionRunItem,
before: Map<String, Any?>,
): String? {
val group =
groups.findById(item.entityId).orElse(null)
?: return "группа ${item.entityId} не найдена"
if (!groupMatches(group, before)) {
return "группа ${group.name}: состояние отличается от плана"
}
return null
}
private fun verifyStudentBefore(
item: PromotionRunItem,
before: Map<String, Any?>,
after: Map<String, Any?>,
): String? {
val student =
students.findById(item.entityId).orElse(null)
?: return "студент ${item.entityId} не найден"
val currentGroupId =
enrollments
.findByStudentAccountId(item.entityId)
?.group
?.id
?.toString()
if (student.status.name != before.statusValue() ||
student.enrollmentYear?.toInt() != before.intValue("enrollmentYear") ||
currentGroupId != before.textValue("groupId")
) {
return "студент ${student.fullName}: состояние отличается от плана"
}
if (item.action == PromotionAction.ACADEMIC && !targetExists(after)) {
return "студент ${student.fullName}: целевая группа недоступна"
}
return null
}
private fun verifyGroupAfter(
item: PromotionRunItem,
after: Map<String, Any?>,
): String? {
val group =
groups.findById(item.entityId).orElse(null)
?: return "группа ${item.entityId} не найдена"
if (!groupMatches(group, after)) {
return "группа ${group.name}: ожидалось состояние после повышения"
}
return null
}
private fun groupMatches(
group: GroupEntity,
state: Map<String, Any?>,
): Boolean =
listOf(
group.status.name == state.statusValue(),
group.course?.toInt() == state.courseValue(),
group.name == state.nameValue(),
group.stream == state.textValue("stream"),
group.degree == state.textValue("degree"),
group.number?.toInt() == state.intValue("number"),
group.enrollmentYear?.toInt() == state.intValue("enrollmentYear"),
).all { it }
private fun verifyStudentAfter(
item: PromotionRunItem,
after: Map<String, Any?>,
): String? {
val student =
students.findById(item.entityId).orElse(null)
?: return "студент ${item.entityId} не найден"
val currentGroupId =
enrollments
.findByStudentAccountId(item.entityId)
?.group
?.id
?.toString()
if (student.status.name != after.statusValue() ||
student.enrollmentYear?.toInt() != after.intValue("enrollmentYear") ||
currentGroupId != after.textValue("groupId")
) {
return "студент ${student.fullName}: ожидалось состояние после повышения"
}
return null
}
private fun targetExists(after: Map<String, Any?>): Boolean {
val target = after.mapValue("targetGroup") ?: return false
if (target["created"] == true) return true
val id = target.uuidValue("id") ?: return false
return groups.findById(id).isPresent
}
}

View File

@@ -0,0 +1,87 @@
package ru.ysu.lms.identity.promotion
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Component
import tools.jackson.databind.ObjectMapper
import java.util.UUID
data class PromotionItemState(
val before: Map<String, Any?>,
val after: Map<String, Any?>,
)
/**
* JSONB-колонки promotion_runs/promotion_run_items пишутся через JdbcTemplate.
*
* Hibernate 7.2 в этом проекте подключает Jackson 2 (`JacksonJsonFormatMapper`),
* а приложение использует Jackson 3, поэтому `@JdbcTypeCode(SqlTypes.JSON)` на
* Jackson 3-типах ненадёжен. JDBC-запись сохраняет совместимость с Jackson 3 и
* работает в одной транзакции с JPA (DataSourceUtils).
*/
@Component
class PromotionJsonStore(
private val jdbc: JdbcTemplate,
private val mapper: ObjectMapper,
) {
fun writeRunResult(
runId: UUID,
result: Map<String, Any?>,
) {
jdbc.update(
"UPDATE identity.promotion_runs SET result = CAST(? AS jsonb) WHERE id = ?",
mapper.writeValueAsString(result),
runId,
)
}
fun readRunResult(runId: UUID): Map<String, Any?>? =
jdbc
.queryForList(
"SELECT result::text FROM identity.promotion_runs WHERE id = ?",
String::class.java,
runId,
).firstOrNull()
?.let(::readMap)
fun writeItemStates(
itemId: UUID,
before: Map<String, Any?>,
after: Map<String, Any?>,
) {
jdbc.update(
"""
UPDATE identity.promotion_run_items
SET before_state = CAST(? AS jsonb),
after_state = CAST(? AS jsonb)
WHERE id = ?
""".trimIndent(),
mapper.writeValueAsString(before),
mapper.writeValueAsString(after),
itemId,
)
}
fun readItemStates(runId: UUID): Map<UUID, PromotionItemState> =
jdbc
.query(
"""
SELECT id, before_state::text AS before_state, after_state::text AS after_state
FROM identity.promotion_run_items
WHERE run_id = ?
""".trimIndent(),
{ rs, _ ->
UUID.fromString(rs.getString("id")) to
PromotionItemState(
before = readMap(rs.getString("before_state")) ?: emptyMap(),
after = readMap(rs.getString("after_state")) ?: emptyMap(),
)
},
runId,
).toMap()
private fun readMap(json: String?): Map<String, Any?>? {
if (json == null) return null
val raw = mapper.readValue(json, Map::class.java) as Map<*, *>
return raw.entries.associate { it.key.toString() to it.value }
}
}

View File

@@ -0,0 +1,342 @@
package ru.ysu.lms.identity.promotion
import org.springframework.stereotype.Component
import ru.ysu.lms.common.Ids
import ru.ysu.lms.identity.domain.Enrollment
import ru.ysu.lms.identity.domain.GroupEntity
import ru.ysu.lms.identity.domain.GroupStatus
import ru.ysu.lms.identity.domain.PromotionAction
import ru.ysu.lms.identity.domain.PromotionEntityType
import ru.ysu.lms.identity.domain.Student
import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.StudentRepository
import java.time.Clock
import java.time.Instant
import java.util.UUID
data class PlannedPromotion(
val entityType: PromotionEntityType,
val entityId: UUID,
val action: PromotionAction,
val before: Map<String, Any?>,
val after: Map<String, Any?>,
)
data class PromotionPlan(
val items: List<PlannedPromotion>,
val result: Map<String, Any?>,
)
private data class TargetKey(
val stream: String,
val number: Short,
val degree: String,
val course: Short,
val enrollmentYear: Short,
)
private data class TargetGroup(
val id: UUID,
val name: String,
val stream: String,
val number: Short,
val degree: String,
val course: Short,
val enrollmentYear: Short,
val created: Boolean,
) {
fun asState(): Map<String, Any?> =
mapOf(
"id" to id.toString(),
"name" to name,
"stream" to stream,
"number" to number.toInt(),
"degree" to degree,
"course" to course.toInt(),
"enrollmentYear" to enrollmentYear.toInt(),
"created" to created,
)
}
private data class AcademicSource(
val stream: String,
val course: Short,
val number: Short,
val degree: String,
)
private data class GroupBasic(
val course: Short,
val degree: String,
val limit: Int,
val stream: String?,
val number: Short?,
)
@Component
class PromotionPlanner(
private val groups: GroupRepository,
private val students: StudentRepository,
private val enrollments: EnrollmentRepository,
private val clock: Clock,
) {
fun plan(year: Int): PromotionPlan {
val warnings = mutableListOf<String>()
val allGroups = groups.findAll()
val enrollmentByStudent = enrollmentIndex()
val groupChanges = planGroups(allGroups, warnings)
val graduatedGroupIds = groupChanges.filter { it.action == PromotionAction.GRADUATE }.map { it.entityId }.toSet()
val targetCache = mutableMapOf<TargetKey, TargetGroup>()
val studentChanges = mutableListOf<PlannedPromotion>()
students.findAll().forEach { student ->
planStudent(student, enrollmentByStudent[student.accountId], year, graduatedGroupIds, targetCache, warnings)
?.let(studentChanges::add)
}
val items = groupChanges + studentChanges
return PromotionPlan(items = items, result = summary(year, items, warnings))
}
private fun enrollmentIndex(): Map<UUID, Enrollment> =
enrollments
.findAllWithStudentAndGroup()
.mapNotNull { enrollment ->
val studentId = enrollment.student?.accountId ?: return@mapNotNull null
studentId to enrollment
}.toMap()
private fun planGroups(
allGroups: List<GroupEntity>,
warnings: MutableList<String>,
): List<PlannedPromotion> =
allGroups
.filter { it.status == GroupStatus.ACTIVE }
.sortedByDescending { it.course ?: 0 }
.mapNotNull { group -> planGroup(group, warnings) }
private fun planGroup(
group: GroupEntity,
warnings: MutableList<String>,
): PlannedPromotion? {
val basic = groupBasic(group)
if (basic == null) {
warnings.add("Группа ${group.name}: не заполнены или некорректны курс/степень — пропущена")
return null
}
val before = groupState(group)
if (basic.course < basic.limit) {
if (basic.stream == null || basic.number == null) {
warnings.add("Группа ${group.name}: не заполнены поток/номер — пропущена")
return null
}
val nextCourse = (basic.course + 1).toShort()
return PlannedPromotion(
entityType = PromotionEntityType.GROUP,
entityId = group.id,
action = PromotionAction.PROMOTE,
before = before,
after =
before +
mapOf(
"course" to nextCourse.toInt(),
"name" to groupName(basic.stream, nextCourse, basic.number, basic.degree),
),
)
}
return PlannedPromotion(
entityType = PromotionEntityType.GROUP,
entityId = group.id,
action = PromotionAction.GRADUATE,
before = before,
after = before + mapOf("status" to GroupStatus.GRADUATED.name),
)
}
private fun groupBasic(group: GroupEntity): GroupBasic? {
val course = group.course ?: return null
val degree = group.degree ?: return null
val limit = courseLimit(degree) ?: return null
return GroupBasic(course, degree, limit, group.stream, group.number)
}
@Suppress("LongParameterList")
private fun planStudent(
student: Student,
enrollment: Enrollment?,
year: Int,
graduatedGroupIds: Set<UUID>,
targetCache: MutableMap<TargetKey, TargetGroup>,
warnings: MutableList<String>,
): PlannedPromotion? =
when (student.status) {
StudentStatus.ACTIVE -> planActiveStudent(student, enrollment, graduatedGroupIds)
StudentStatus.ACADEMIC_LEAVE ->
planAcademicStudent(student, enrollment, year, targetCache, warnings)
StudentStatus.EXPELLED, StudentStatus.GRADUATED, StudentStatus.ARCHIVED -> null
}
private fun planActiveStudent(
student: Student,
enrollment: Enrollment?,
graduatedGroupIds: Set<UUID>,
): PlannedPromotion? {
val group = enrollment?.group ?: return null
if (group.id !in graduatedGroupIds) return null
val before = studentState(student, group)
return PlannedPromotion(
entityType = PromotionEntityType.STUDENT,
entityId = student.accountId!!,
action = PromotionAction.GRADUATE,
before = before,
after = before + mapOf("status" to StudentStatus.GRADUATED.name),
)
}
@Suppress("LongParameterList")
private fun planAcademicStudent(
student: Student,
enrollment: Enrollment?,
year: Int,
targetCache: MutableMap<TargetKey, TargetGroup>,
warnings: MutableList<String>,
): PlannedPromotion? {
val group = enrollment?.group
if (group == null) {
warnings.add("Студент ${student.fullName}: нет зачисления — академ пропущен")
return null
}
val source = academicSource(group)
if (source == null) {
warnings.add("Студент ${student.fullName}: у группы ${group.name} не заполнены поля — академ пропущен")
return null
}
val targetYear = year - (source.course - 1)
if (targetYear < 2000 || targetYear > 2100) {
warnings.add("Студент ${student.fullName}: некорректный год целевой группы $targetYear")
return null
}
val key = TargetKey(source.stream, source.number, source.degree, source.course, targetYear.toShort())
val target = targetCache.getOrPut(key) { resolveTarget(key) }
val before = studentState(student, group)
val after =
before +
mapOf(
"status" to StudentStatus.ACADEMIC_LEAVE.name,
"enrollmentYear" to target.enrollmentYear.toInt(),
"groupId" to target.id.toString(),
"groupName" to target.name,
"targetGroup" to target.asState(),
)
return PlannedPromotion(
entityType = PromotionEntityType.STUDENT,
entityId = student.accountId!!,
action = PromotionAction.ACADEMIC,
before = before,
after = after,
)
}
private fun academicSource(group: GroupEntity): AcademicSource? {
val stream = group.stream
val course = group.course
if (stream == null || course == null) return null
val number = group.number
val degree = group.degree
if (number == null || degree == null) return null
return AcademicSource(stream, course, number, degree)
}
private fun resolveTarget(key: TargetKey): TargetGroup {
val existing =
groups.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
key.stream,
key.degree,
key.number,
key.course,
key.enrollmentYear,
)
if (existing != null && existing.status == GroupStatus.ACTIVE) {
return TargetGroup(
id = existing.id,
name = existing.name,
stream = key.stream,
number = key.number,
degree = key.degree,
course = key.course,
enrollmentYear = key.enrollmentYear,
created = false,
)
}
return TargetGroup(
id = Ids.v7(),
name = groupName(key.stream, key.course, key.number, key.degree),
stream = key.stream,
number = key.number,
degree = key.degree,
course = key.course,
enrollmentYear = key.enrollmentYear,
created = true,
)
}
private fun summary(
year: Int,
items: List<PlannedPromotion>,
warnings: List<String>,
): Map<String, Any?> =
mapOf(
"year" to year,
"generatedAt" to Instant.now(clock).toString(),
"counts" to
mapOf(
"groupsPromote" to items.count { it.entityType == PromotionEntityType.GROUP && it.action == PromotionAction.PROMOTE },
"groupsGraduate" to items.count { it.entityType == PromotionEntityType.GROUP && it.action == PromotionAction.GRADUATE },
"studentsGraduate" to
items.count { it.entityType == PromotionEntityType.STUDENT && it.action == PromotionAction.GRADUATE },
"studentsAcademic" to
items.count { it.entityType == PromotionEntityType.STUDENT && it.action == PromotionAction.ACADEMIC },
),
"warnings" to warnings,
)
private fun groupState(group: GroupEntity): Map<String, Any?> =
mapOf(
"id" to group.id.toString(),
"name" to group.name,
"stream" to group.stream,
"course" to group.course?.toInt(),
"number" to group.number?.toInt(),
"degree" to group.degree,
"enrollmentYear" to group.enrollmentYear?.toInt(),
"status" to group.status.name,
)
private fun studentState(
student: Student,
group: GroupEntity,
): Map<String, Any?> =
mapOf(
"id" to student.accountId.toString(),
"studentNo" to student.studentNo,
"fullName" to student.fullName,
"status" to student.status.name,
"enrollmentYear" to student.enrollmentYear?.toInt(),
"groupId" to group.id.toString(),
"groupName" to group.name,
)
private fun groupName(
stream: String,
course: Short,
number: Short,
degree: String,
): String = "$stream-$course$number$degree"
private fun courseLimit(degree: String): Int? =
when (degree) {
"БО" -> 4
"МО" -> 2
else -> null
}
}

View File

@@ -0,0 +1,187 @@
package ru.ysu.lms.identity.promotion
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.identity.domain.GroupStatus
import ru.ysu.lms.identity.domain.MembershipAction
import ru.ysu.lms.identity.domain.MembershipHistory
import ru.ysu.lms.identity.domain.PromotionAction
import ru.ysu.lms.identity.domain.PromotionEntityType
import ru.ysu.lms.identity.domain.PromotionItemStatus
import ru.ysu.lms.identity.domain.PromotionRun
import ru.ysu.lms.identity.domain.PromotionRunItem
import ru.ysu.lms.identity.domain.PromotionRunStatus
import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.MembershipHistoryRepository
import ru.ysu.lms.identity.repo.PromotionRunItemRepository
import ru.ysu.lms.identity.repo.PromotionRunRepository
import ru.ysu.lms.identity.repo.StudentRepository
import java.time.Clock
import java.time.Instant
import java.util.UUID
@Suppress("LongParameterList")
@Component
class PromotionRollback(
private val runs: PromotionRunRepository,
private val items: PromotionRunItemRepository,
private val json: PromotionJsonStore,
private val groups: GroupRepository,
private val students: StudentRepository,
private val enrollments: EnrollmentRepository,
private val history: MembershipHistoryRepository,
private val verifier: PromotionItemVerifier,
private val runLock: PromotionRunLock,
private val clock: Clock,
) {
@Transactional
fun rollback(
runId: UUID,
actorAccountId: UUID?,
) {
runLock.acquire(runId)
val run = runs.findById(runId).orElseThrow { NotFoundException("Запуск повышения не найден") }
if (run.status != PromotionRunStatus.COMPLETED) {
promotionConflict("Откатить можно только завершённое повышение (статус ${run.status})")
}
val states = json.readItemStates(runId)
val applied =
items
.findByRunIdOrderByIdAsc(runId)
.filter { it.status == PromotionItemStatus.APPLIED }
.map { OrderedRollbackItem(it, states[it.id]) }
.sortedWith(rollbackOrder())
.reversed()
val conflicts = applied.mapNotNull { verifier.verifyAfter(it.item, it.state) }
if (conflicts.isNotEmpty()) {
promotionConflict("Ручные изменения мешают откату: ${conflicts.joinToString("; ")}")
}
applied.forEach { restoreItem(it, run, actorAccountId) }
deleteCreatedGroups(applied)
run.status = PromotionRunStatus.ROLLED_BACK
run.finishedAt = Instant.now(clock)
runs.saveAndFlush(run)
updateResult(run.id, mapOf("status" to "ROLLED_BACK", "rolledBackAt" to Instant.now(clock).toString()))
}
private fun restoreItem(
ordered: OrderedRollbackItem,
run: PromotionRun,
actorAccountId: UUID?,
) {
val state = ordered.state ?: throw ConflictException("Нет before/after для элемента ${ordered.item.id}")
when (ordered.item.entityType) {
PromotionEntityType.GROUP -> restoreGroup(ordered.item, state, run, actorAccountId)
PromotionEntityType.STUDENT -> restoreStudent(ordered.item, state, run, actorAccountId)
}
ordered.item.status = PromotionItemStatus.ROLLED_BACK
ordered.item.error = ""
items.save(ordered.item)
}
private fun restoreGroup(
item: PromotionRunItem,
state: PromotionItemState,
run: PromotionRun,
actorAccountId: UUID?,
) {
val group = groups.findById(item.entityId).orElseThrow { NotFoundException("Группа ${item.entityId} не найдена") }
group.name = state.before.nameValue() ?: group.name
group.course = state.before.courseValue()?.toShort() ?: group.course
group.status = GroupStatus.valueOf(state.before.statusValue() ?: group.status.name)
groups.save(group)
if (item.action == PromotionAction.PROMOTE) {
activeEnrollments(group.id).forEach { enrollment ->
history.save(
MembershipHistory(
studentId = enrollment.student!!.accountId!!,
fromGroupId = group.id,
toGroupId = group.id,
action = MembershipAction.RETURN,
reason = "Откат повышения ${run.year}",
actorAccountId = actorAccountId,
promotionRunId = run.id,
),
)
}
}
}
private fun restoreStudent(
item: PromotionRunItem,
state: PromotionItemState,
run: PromotionRun,
actorAccountId: UUID?,
) {
val student = students.findById(item.entityId).orElseThrow { NotFoundException("Студент ${item.entityId} не найден") }
val beforeGroupId = state.before.uuidValue("groupId")
val afterGroupId = state.after.uuidValue("groupId")
val enrollment =
enrollments.findByStudentAccountId(student.accountId!!)
?: throw ConflictException("У студента ${student.fullName} нет зачисления")
if (beforeGroupId != null && enrollment.group?.id != beforeGroupId) {
enrollment.group =
groups.findById(beforeGroupId).orElseThrow {
ConflictException("Группа $beforeGroupId для отката не найдена")
}
enrollments.save(enrollment)
}
student.status = StudentStatus.valueOf(state.before.statusValue() ?: student.status.name)
student.enrollmentYear = state.before.intValue("enrollmentYear")?.toShort()
students.save(student)
history.save(
MembershipHistory(
studentId = student.accountId!!,
fromGroupId = if (item.action == PromotionAction.ACADEMIC) afterGroupId else null,
toGroupId = beforeGroupId,
action = MembershipAction.RETURN,
reason = "Откат повышения ${run.year}",
actorAccountId = actorAccountId,
promotionRunId = run.id,
),
)
}
private fun deleteCreatedGroups(applied: List<OrderedRollbackItem>) {
val createdIds =
applied
.filter { it.item.entityType == PromotionEntityType.STUDENT && it.item.action == PromotionAction.ACADEMIC }
.mapNotNull { it.state?.after?.mapValue("targetGroup") }
.filter { it["created"] == true }
.mapNotNull { it.uuidValue("id") }
.distinct()
createdIds.forEach { groupId ->
if (enrollments.countByGroupId(groupId) == 0L) {
groups.findById(groupId).orElse(null)?.let(groups::delete)
}
}
}
private fun rollbackOrder(): Comparator<OrderedRollbackItem> =
compareBy(
{ if (it.item.entityType == PromotionEntityType.GROUP) 0 else 1 },
{ if (it.item.entityType == PromotionEntityType.GROUP) -(it.state?.before?.courseValue() ?: 0) else 0 },
{ it.item.action.ordinal },
{ it.item.entityId.toString() },
)
private fun activeEnrollments(groupId: UUID) =
enrollments.findAllByGroupId(groupId).filter { it.student?.status == StudentStatus.ACTIVE }
private fun updateResult(
runId: UUID,
extra: Map<String, Any?>,
) {
val current = json.readRunResult(runId) ?: emptyMap()
json.writeRunResult(runId, current + extra)
}
}
private data class OrderedRollbackItem(
val item: PromotionRunItem,
val state: PromotionItemState?,
)

View File

@@ -0,0 +1,28 @@
package ru.ysu.lms.identity.promotion
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.core.ResultSetExtractor
import org.springframework.stereotype.Component
import java.util.UUID
/**
* Транзакционная блокировка на конкретный запуск повышения.
*
* Блокировка удерживается до конца текущей транзакции, поэтому повторный
* apply/rollback того же запуска не может параллельно продублировать историю.
*/
@Component
class PromotionRunLock(
private val jdbc: JdbcTemplate,
) {
fun acquire(runId: UUID) {
jdbc.query(
"SELECT pg_advisory_xact_lock(hashtext(?))",
ResultSetExtractor { rs ->
rs.next()
Unit
},
"promotion-run-$runId",
)
}
}

View File

@@ -0,0 +1,34 @@
package ru.ysu.lms.identity.promotion
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
/** Автоматический dry-run 31 августа 00:00 МСК + догон при старте сервиса. */
@Component
@ConditionalOnProperty(
prefix = "lms.promotion",
name = ["scheduler-enabled"],
havingValue = "true",
matchIfMissing = true,
)
class PromotionScheduler(
private val promotionService: PromotionService,
) {
private val log = LoggerFactory.getLogger(javaClass)
@Scheduled(cron = "0 0 0 31 8 *", zone = "Europe/Moscow")
fun scheduledDryRun() {
runCatching { promotionService.ensureScheduledDryRun() }
.onFailure { log.error("Не удалось построить плановый dry-run повышения", it) }
}
@EventListener(ApplicationReadyEvent::class)
fun catchUpDryRun() {
runCatching { promotionService.ensureScheduledDryRun() }
.onFailure { log.error("Догон dry-run повышения при старте не удался", it) }
}
}

View File

@@ -0,0 +1,221 @@
package ru.ysu.lms.identity.promotion
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.identity.domain.PromotionItemStatus
import ru.ysu.lms.identity.domain.PromotionMode
import ru.ysu.lms.identity.domain.PromotionRun
import ru.ysu.lms.identity.domain.PromotionRunItem
import ru.ysu.lms.identity.domain.PromotionRunStatus
import ru.ysu.lms.identity.repo.PromotionRunItemRepository
import ru.ysu.lms.identity.repo.PromotionRunRepository
import java.time.Clock
import java.time.Instant
import java.time.LocalDate
import java.util.UUID
@Suppress("LongParameterList")
@Service
class PromotionService(
private val runs: PromotionRunRepository,
private val items: PromotionRunItemRepository,
private val json: PromotionJsonStore,
private val planner: PromotionPlanner,
private val applier: PromotionApplier,
private val rollback: PromotionRollback,
private val runLock: PromotionRunLock,
private val jdbc: JdbcTemplate,
private val clock: Clock,
) {
@Transactional(readOnly = true)
fun list(): List<PromotionRunSummary> = runs.findAllByOrderByYearDescStartedAtDesc().map(::summary)
@Transactional(readOnly = true)
fun report(runId: UUID): PromotionRunDetail = detail(runId)
@Transactional
fun dryRun(
year: Int? = null,
actorAccountId: UUID? = null,
): PromotionRunDetail {
val targetYear = year ?: LocalDate.now(clock).year
validateYear(targetYear)
val existing = runs.findFirstByYearAndModeOrderByStartedAtDesc(targetYear, PromotionMode.DRY_RUN)
if (existing != null && existing.status == PromotionRunStatus.PLANNED) {
return detail(existing.id)
}
if (runs.existsByYearAndModeAndStatusIn(targetYear, PromotionMode.APPLY, ACTIVE_APPLY_STATUSES)) {
throw ConflictException("Повышение за $targetYear уже применено; сначала выполните откат")
}
return createPlan(targetYear, actorAccountId)
}
@Transactional
fun apply(
runId: UUID,
actorAccountId: UUID?,
): PromotionRunDetail {
applier.apply(runId, actorAccountId)
return detail(runId)
}
@Transactional
fun cancel(
runId: UUID,
actorAccountId: UUID?,
): PromotionRunDetail {
runLock.acquire(runId)
val run = runs.findById(runId).orElseThrow { NotFoundException("Запуск повышения не найден") }
if (run.status != PromotionRunStatus.PLANNED || run.mode != PromotionMode.DRY_RUN) {
throw ConflictException("Отменить можно только запланированный dry-run (статус ${run.status})")
}
val now = Instant.now(clock)
run.status = PromotionRunStatus.FAILED
run.finishedAt = now
runs.saveAndFlush(run)
updateResult(
run.id,
mapOf(
"status" to "CANCELLED",
"cancelledAt" to now.toString(),
"cancelledBy" to actorAccountId?.toString(),
),
)
return detail(run.id)
}
@Transactional
fun rollback(
runId: UUID,
actorAccountId: UUID?,
): PromotionRunDetail {
rollback.rollback(runId, actorAccountId)
return detail(runId)
}
@Transactional
fun ensureScheduledDryRun(): PromotionRunDetail? = ensureScheduledDryRun(LocalDate.now(clock))
/**
* Идемпотентная страховка для планировщика и догона при старте.
* Блокировка advisory lock не даёт двум инстансам создать два плана.
*/
@Transactional
fun ensureScheduledDryRun(today: LocalDate): PromotionRunDetail? {
val year = today.year
if (today.isBefore(LocalDate.of(year, AUGUST, LAST_DAY_OF_AUGUST))) return null
val acquired =
jdbc.queryForObject(
"SELECT pg_try_advisory_xact_lock(hashtext(?))",
Boolean::class.java,
"promotion-ensure-$year",
) ?: false
if (!acquired || runs.existsByYear(year) || runs.existsByYearAndModeAndStatusIn(year, PromotionMode.APPLY, ACTIVE_APPLY_STATUSES)) {
return null
}
return createPlan(year, null)
}
private fun createPlan(
year: Int,
actorAccountId: UUID?,
): PromotionRunDetail {
val plan = planner.plan(year)
val now = Instant.now(clock)
val run =
runs.saveAndFlush(
PromotionRun(
year = year,
mode = PromotionMode.DRY_RUN,
actorAccountId = actorAccountId,
status = PromotionRunStatus.PLANNED,
startedAt = now,
),
)
val entities =
plan.items.map { planned ->
PromotionRunItem(
runId = run.id,
entityType = planned.entityType,
entityId = planned.entityId,
action = planned.action,
status = PromotionItemStatus.PLANNED,
createdAt = now,
)
}
val saved = items.saveAllAndFlush(entities)
plan.items.zip(saved).forEach { (planned, item) ->
json.writeItemStates(item.id, planned.before, planned.after)
}
json.writeRunResult(run.id, plan.result)
return detail(run.id)
}
private fun detail(runId: UUID): PromotionRunDetail {
val run = runs.findById(runId).orElseThrow { NotFoundException("Запуск повышения не найден") }
val states = json.readItemStates(runId)
val itemViews =
items.findByRunIdOrderByIdAsc(runId).map { item ->
val state = states[item.id]
PromotionItemView(
id = item.id.toString(),
entityType = item.entityType.name,
entityId = item.entityId.toString(),
action = item.action.name,
status = item.status.name,
error = item.error,
createdAt = item.createdAt,
before = state?.before ?: emptyMap(),
after = state?.after ?: emptyMap(),
)
}
return PromotionRunDetail(
id = run.id.toString(),
year = run.year,
mode = run.mode.name,
status = run.status.name,
actorAccountId = run.actorAccountId?.toString(),
startedAt = run.startedAt,
finishedAt = run.finishedAt,
result = json.readRunResult(runId),
items = itemViews,
)
}
private fun summary(run: PromotionRun): PromotionRunSummary =
PromotionRunSummary(
id = run.id.toString(),
year = run.year,
mode = run.mode.name,
status = run.status.name,
actorAccountId = run.actorAccountId?.toString(),
startedAt = run.startedAt,
finishedAt = run.finishedAt,
result = json.readRunResult(run.id),
)
private fun validateYear(year: Int) {
if (year !in YEAR_RANGE) {
throw BadRequestException("Год повышения вне диапазона ${YEAR_RANGE.first}–${YEAR_RANGE.last}")
}
}
private fun updateResult(
runId: UUID,
extra: Map<String, Any?>,
) {
val current = json.readRunResult(runId) ?: emptyMap()
json.writeRunResult(runId, current + extra)
}
private companion object {
const val AUGUST = 8
const val LAST_DAY_OF_AUGUST = 31
val YEAR_RANGE = 2000..2100
val ACTIVE_APPLY_STATUSES = listOf(PromotionRunStatus.RUNNING, PromotionRunStatus.COMPLETED)
}
}

View File

@@ -0,0 +1,21 @@
package ru.ysu.lms.identity.promotion
import ru.ysu.lms.common.ConflictException
import java.util.UUID
internal fun promotionConflict(message: String): Nothing = throw ConflictException(message)
internal fun Map<String, Any?>.textValue(key: String): String? = this[key] as? String
internal fun Map<String, Any?>.intValue(key: String): Int? = (this[key] as? Number)?.toInt()
internal fun Map<String, Any?>.uuidValue(key: String): UUID? = textValue(key)?.let(UUID::fromString)
internal fun Map<String, Any?>.mapValue(key: String): Map<String, Any?>? =
(this[key] as? Map<*, *>)?.entries?.associate { it.key.toString() to it.value }
internal fun Map<String, Any?>.courseValue(): Int? = intValue("course")
internal fun Map<String, Any?>.statusValue(): String? = textValue("status")
internal fun Map<String, Any?>.nameValue(): String? = textValue("name")

View File

@@ -1,5 +1,6 @@
package ru.ysu.lms.identity.repo
import org.springframework.data.jpa.repository.EntityGraph
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
@@ -14,6 +15,10 @@ interface EnrollmentRepository : JpaRepository<Enrollment, UUID> {
@Param("groupId") groupId: UUID,
): List<Enrollment>
@Query("select e from Enrollment e join fetch e.student join fetch e.group")
fun findAllWithStudentAndGroup(): List<Enrollment>
@EntityGraph(attributePaths = ["group"])
fun findByStudentAccountId(accountId: UUID): Enrollment?
fun findFirstByStudentAccountId(accountId: UUID): Enrollment?

View File

@@ -0,0 +1,9 @@
package ru.ysu.lms.identity.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.identity.domain.PromotionRunItem
import java.util.UUID
interface PromotionRunItemRepository : JpaRepository<PromotionRunItem, UUID> {
fun findByRunIdOrderByIdAsc(runId: UUID): List<PromotionRunItem>
}

View File

@@ -0,0 +1,30 @@
package ru.ysu.lms.identity.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.identity.domain.PromotionMode
import ru.ysu.lms.identity.domain.PromotionRun
import ru.ysu.lms.identity.domain.PromotionRunStatus
import java.util.UUID
interface PromotionRunRepository : JpaRepository<PromotionRun, UUID> {
fun findAllByOrderByYearDescStartedAtDesc(): List<PromotionRun>
fun findFirstByYearAndModeOrderByStartedAtDesc(
year: Int,
mode: PromotionMode,
): PromotionRun?
fun findFirstByYearAndModeAndStatusInOrderByStartedAtDesc(
year: Int,
mode: PromotionMode,
statuses: Collection<PromotionRunStatus>,
): PromotionRun?
fun existsByYear(year: Int): Boolean
fun existsByYearAndModeAndStatusIn(
year: Int,
mode: PromotionMode,
statuses: Collection<PromotionRunStatus>,
): Boolean
}

View File

@@ -87,6 +87,12 @@ class GroupController(
@PathVariable id: UUID,
): GroupDetail = groupService.detail(id)
/** Архив группы без массового изменения статусов студентов. */
@PostMapping("/{id}/archive")
fun archive(
@PathVariable id: UUID,
): GroupDetail = groupService.archiveGroup(id)
@GetMapping("/{id}/credentials")
fun credentials(
@PathVariable id: UUID,

View File

@@ -270,11 +270,27 @@ class GroupService(
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
}
/** Удаляет студента из identity: зачисление, профиль, аккаунт. */
/** Архивирует группу, не меняя статусы студентов. */
@Transactional
fun archiveGroup(groupId: UUID): GroupDetail {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
if (group.status == GroupStatus.ARCHIVED) {
throw BadRequestException("Группа ${group.name} уже в архиве")
}
group.status = GroupStatus.ARCHIVED
groups.save(group)
return detail(groupId)
}
/**
* Удаляет студента из identity: зачисление, профиль, аккаунт.
* [confirm] должен совпадать с логином аккаунта или ФИО студента.
*/
@Transactional
fun deleteUser(
accountId: UUID,
currentAccountId: UUID,
confirm: String,
): DeleteUserResult {
if (accountId == currentAccountId) {
throw BadRequestException("Нельзя удалить собственный аккаунт")
@@ -285,12 +301,26 @@ class GroupService(
}
val student = students.findById(accountId).orElse(null)
val fullName = student?.fullName.orEmpty()
validateDeleteConfirmation(account, fullName, confirm)
enrollments.findByStudentAccountId(accountId)?.let { enrollments.delete(it) }
if (student != null) students.delete(student)
accounts.delete(account)
return DeleteUserResult(accountId.toString(), account.login, fullName)
}
private fun validateDeleteConfirmation(
account: Account,
fullName: String,
confirm: String,
) {
val expected = setOf(account.login, fullName).filter { it.isNotBlank() }
if (confirm.trim() !in expected) {
throw BadRequestException(
"Неверное подтверждение удаления: укажите логин ${account.login} или ФИО ${fullName.ifBlank { "студента" }}",
)
}
}
@Transactional
fun resetPassword(studentAccountId: UUID): ResetPasswordResponse {
val student =

View File

@@ -29,10 +29,11 @@ class StudentController(
@RequestParam(required = false, name = "q") query: String?,
): List<AdminUser> = groupService.users(query)
/** Полное удаление пользователя из identity. */
/** Полное удаление пользователя из identity; [confirm] — логин или ФИО студента. */
@DeleteMapping("/{id}")
fun delete(
@PathVariable id: UUID,
@RequestParam("confirm") confirm: String,
@AuthenticationPrincipal jwt: Jwt,
): DeleteUserResult = groupService.deleteUser(id, UUID.fromString(jwt.subject))
): DeleteUserResult = groupService.deleteUser(id, UUID.fromString(jwt.subject), confirm)
}

View File

@@ -0,0 +1,55 @@
package ru.ysu.lms.identity.roster
import jakarta.validation.Valid
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.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/api/v1/students")
@PreAuthorize("hasRole('ADMIN')")
class StudentLifecycleController(
private val lifecycle: StudentLifecycleService,
) {
@PostMapping("/{id}/academic-leave")
fun academicLeave(
@PathVariable id: UUID,
@RequestBody(required = false) body: StudentReasonRequest?,
@AuthenticationPrincipal jwt: Jwt,
): StudentLifecycleResult = lifecycle.academicLeave(id, body, actor(jwt))
@PostMapping("/{id}/return")
fun returnFromLeave(
@PathVariable id: UUID,
@Valid @RequestBody body: StudentGroupRequest,
@AuthenticationPrincipal jwt: Jwt,
): StudentLifecycleResult = lifecycle.returnFromLeave(id, body, actor(jwt))
@PostMapping("/{id}/expel")
fun expel(
@PathVariable id: UUID,
@RequestBody(required = false) body: StudentReasonRequest?,
@AuthenticationPrincipal jwt: Jwt,
): StudentLifecycleResult = lifecycle.expel(id, body, actor(jwt))
@PostMapping("/{id}/archive")
fun archive(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): StudentLifecycleResult = lifecycle.archive(id, actor(jwt))
@PostMapping("/{id}/restore")
fun restore(
@PathVariable id: UUID,
@Valid @RequestBody body: StudentGroupRequest,
@AuthenticationPrincipal jwt: Jwt,
): StudentLifecycleResult = lifecycle.restore(id, body, actor(jwt))
private fun actor(jwt: Jwt): UUID = UUID.fromString(jwt.subject)
}

View File

@@ -0,0 +1,29 @@
package ru.ysu.lms.identity.roster
import jakarta.validation.constraints.NotNull
import java.util.UUID
/** Тело операций жизненного цикла, где нужна только причина. */
data class StudentReasonRequest(
val reason: String? = null,
)
/** Возврат из академотпуска и восстановление из архива требуют целевую группу. */
data class StudentGroupRequest(
@field:NotNull(message = "Укажите группу")
val groupId: UUID? = null,
val reason: String? = null,
)
/** Компактный результат операции жизненного цикла студента. */
data class StudentLifecycleResult(
val studentId: String,
val fullName: String,
val status: String,
val action: String,
val fromGroupId: String? = null,
val fromGroupName: String? = null,
val toGroupId: String? = null,
val toGroupName: String? = null,
val enrollmentYear: Short? = null,
)

View File

@@ -0,0 +1,274 @@
package ru.ysu.lms.identity.roster
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.AccountStatus
import ru.ysu.lms.identity.domain.Enrollment
import ru.ysu.lms.identity.domain.GroupEntity
import ru.ysu.lms.identity.domain.GroupStatus
import ru.ysu.lms.identity.domain.MembershipAction
import ru.ysu.lms.identity.domain.MembershipHistory
import ru.ysu.lms.identity.domain.Student
import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.AccountRepository
import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.MembershipHistoryRepository
import ru.ysu.lms.identity.repo.StudentRepository
import java.time.Instant
import java.util.UUID
/**
* Жизненный цикл студента: академотпуск, возврат, отчисление, архив и восстановление.
*
* Зачисление всегда остаётся одной записью на студента. Отчисление и архив сохраняют
* текущую группу в зачислении, чтобы история и отчёты не теряли место учёбы; вход
* блокируется статусом студента (и статусом аккаунта для архива).
*/
@Service
class StudentLifecycleService(
private val students: StudentRepository,
private val accounts: AccountRepository,
private val groups: GroupRepository,
private val enrollments: EnrollmentRepository,
private val history: MembershipHistoryRepository,
private val cohortResolver: GroupCohortResolver,
) {
/**
* Академотпуск: студент переезжает в группу того же курса следующего набора.
* Год набора считается как `текущий учебный год - (курс - 1)`, поэтому после
* возврата студент догоняет свою же параллель на год младше.
*/
@Transactional
fun academicLeave(
studentId: UUID,
request: StudentReasonRequest?,
actorAccountId: UUID?,
): StudentLifecycleResult {
val student = loadStudent(studentId)
requireStatus(student, StudentStatus.ACTIVE, "Академический отпуск доступен только активному студенту")
val enrollment = currentEnrollment(studentId)
val source = enrollment.group ?: throw BadRequestException("У студента нет текущей группы")
val target = resolveTargetGroup(academicCohort(source))
moveEnrollment(enrollment, target)
student.status = StudentStatus.ACADEMIC_LEAVE
student.enrollmentYear = target.enrollmentYear
students.save(student)
record(studentId, source.id, target.id, MembershipAction.ACADEMIC, request?.reason, actorAccountId)
return result(student, MembershipAction.ACADEMIC, source, target)
}
/** Возврат из академотпуска в указанную активную группу. */
@Transactional
fun returnFromLeave(
studentId: UUID,
request: StudentGroupRequest,
actorAccountId: UUID?,
): StudentLifecycleResult {
val student = loadStudent(studentId)
requireStatus(student, StudentStatus.ACADEMIC_LEAVE, "Вернуть можно только студента в академическом отпуске")
val target = activeGroup(request.groupId ?: throw BadRequestException("Укажите группу возврата"))
val enrollment = currentEnrollment(studentId)
val source = enrollment.group ?: throw BadRequestException("У студента нет текущей группы")
moveEnrollment(enrollment, target)
student.status = StudentStatus.ACTIVE
student.enrollmentYear = target.enrollmentYear
students.save(student)
record(studentId, source.id, target.id, MembershipAction.RETURN, request.reason, actorAccountId)
return result(student, MembershipAction.RETURN, source, target)
}
/**
* Отчисление. Зачисление в текущую группу сохраняется для истории, но статус
* EXPELLED закрывает вход ([ru.ysu.lms.identity.auth.AuthService]).
*/
@Transactional
fun expel(
studentId: UUID,
request: StudentReasonRequest?,
actorAccountId: UUID?,
): StudentLifecycleResult {
val student = loadStudent(studentId)
val blocked = setOf(StudentStatus.ARCHIVED, StudentStatus.GRADUATED, StudentStatus.EXPELLED)
if (student.status in blocked) {
throw BadRequestException("Нельзя отчислить студента в статусе ${student.status.name}")
}
val source = enrollments.findByStudentAccountId(studentId)?.group
student.status = StudentStatus.EXPELLED
students.save(student)
record(studentId, source?.id, null, MembershipAction.EXPELL, request?.reason, actorAccountId)
return result(student, MembershipAction.EXPELL, source, null)
}
/** Архив закрывает и профиль студента, и аккаунт; зачисление сохраняется. */
@Transactional
fun archive(
studentId: UUID,
actorAccountId: UUID?,
): StudentLifecycleResult {
val student = loadStudent(studentId)
if (student.status == StudentStatus.ARCHIVED) {
throw BadRequestException("Студент уже в архиве")
}
val now = Instant.now()
val account = loadAccount(studentId)
account.status = AccountStatus.ARCHIVED
account.archivedAt = now
account.updatedAt = now
accounts.save(account)
val source = enrollments.findByStudentAccountId(studentId)?.group
student.status = StudentStatus.ARCHIVED
students.save(student)
record(studentId, source?.id, null, MembershipAction.ARCHIVE, null, actorAccountId)
return result(student, MembershipAction.ARCHIVE, source, null)
}
/** Восстановление из архива или после отчисления в выбранную активную группу. */
@Transactional
fun restore(
studentId: UUID,
request: StudentGroupRequest,
actorAccountId: UUID?,
): StudentLifecycleResult {
val student = loadStudent(studentId)
if (student.status !in setOf(StudentStatus.ARCHIVED, StudentStatus.EXPELLED)) {
throw BadRequestException("Восстановить можно только студента из архива или после отчисления")
}
val target = activeGroup(request.groupId ?: throw BadRequestException("Укажите группу восстановления"))
val account = loadAccount(studentId)
account.status = AccountStatus.ACTIVE
account.archivedAt = null
account.updatedAt = Instant.now()
accounts.save(account)
val enrollment = enrollments.findByStudentAccountId(studentId)
val source = enrollment?.group
if (enrollment == null) {
enrollments.save(Enrollment(group = target, student = student))
} else {
moveEnrollment(enrollment, target)
}
student.status = StudentStatus.ACTIVE
student.enrollmentYear = target.enrollmentYear
students.save(student)
record(studentId, source?.id, target.id, MembershipAction.RESTORE, request.reason, actorAccountId)
return result(student, MembershipAction.RESTORE, source, target)
}
private fun academicCohort(source: GroupEntity): GroupCohort {
val stream = source.stream ?: invalidSourceGroup(source)
val course = source.course ?: invalidSourceGroup(source)
val number = source.number ?: invalidSourceGroup(source)
val degree = source.degree ?: invalidSourceGroup(source)
val year = cohortResolver.defaultEnrollmentYear(course)
return cohortResolver.validate(GroupCohort(stream, course, number, degree, year))
}
private fun resolveTargetGroup(cohort: GroupCohort): GroupEntity {
val existing =
groups.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
cohort.stream,
cohort.degree,
cohort.number,
cohort.course,
cohort.enrollmentYear,
)
if (existing != null) {
if (existing.status != GroupStatus.ACTIVE) {
throw ConflictException("Группа ${existing.name} существует, но не активна")
}
return existing
}
return groups.save(
GroupEntity(
name = cohort.name,
stream = cohort.stream,
course = cohort.course,
number = cohort.number,
degree = cohort.degree,
enrollmentYear = cohort.enrollmentYear,
status = GroupStatus.ACTIVE,
),
)
}
private fun invalidSourceGroup(group: GroupEntity): Nothing =
throw BadRequestException("У группы ${group.name} не заполнены поток, курс, номер или степень")
private fun loadStudent(studentId: UUID): Student =
students.findById(studentId).orElse(null)
?: throw NotFoundException("Студент не найден")
private fun loadAccount(studentId: UUID): Account =
accounts.findById(studentId).orElse(null)
?: throw NotFoundException("Аккаунт студента не найден")
private fun currentEnrollment(studentId: UUID): Enrollment =
enrollments.findByStudentAccountId(studentId)
?: throw BadRequestException("У студента нет текущей группы")
private fun activeGroup(groupId: UUID): GroupEntity {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
if (group.status != GroupStatus.ACTIVE) {
throw BadRequestException("Группа ${group.name} не активна")
}
return group
}
private fun moveEnrollment(
enrollment: Enrollment,
target: GroupEntity,
) {
enrollment.group = target
enrollments.save(enrollment)
}
private fun requireStatus(
student: Student,
expected: StudentStatus,
message: String,
) {
if (student.status != expected) throw BadRequestException(message)
}
private fun record(
studentId: UUID,
fromGroupId: UUID?,
toGroupId: UUID?,
action: MembershipAction,
reason: String?,
actorAccountId: UUID? = null,
) {
history.save(
MembershipHistory(
studentId = studentId,
fromGroupId = fromGroupId,
toGroupId = toGroupId,
action = action,
reason = reason?.trim().orEmpty(),
actorAccountId = actorAccountId,
),
)
}
private fun result(
student: Student,
action: MembershipAction,
from: GroupEntity?,
to: GroupEntity?,
): StudentLifecycleResult =
StudentLifecycleResult(
studentId = student.accountId.toString(),
fullName = student.fullName,
status = student.status.name,
action = action.name,
fromGroupId = from?.id?.toString(),
fromGroupName = from?.name,
toGroupId = to?.id?.toString(),
toGroupName = to?.name,
enrollmentYear = student.enrollmentYear,
)
}

View File

@@ -20,6 +20,7 @@ import ru.ysu.lms.identity.auth.LoginRequest
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.LoginAttempt
import ru.ysu.lms.identity.repo.AccountRepository
import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.LoginAttemptRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.JwtProperties
@@ -47,6 +48,8 @@ class AuthServiceLoginBackoffTest {
): AuthService {
val students = mockk<StudentRepository>()
every { students.findById(account.id) } returns Optional.empty()
val enrollments = mockk<EnrollmentRepository>()
every { enrollments.findByStudentAccountId(account.id) } returns null
val passwordEncoder = mockk<PasswordEncoder>()
every { passwordEncoder.matches(any(), any()) } returns matches
val jwtEncoder = mockk<JwtEncoder>()
@@ -56,6 +59,7 @@ class AuthServiceLoginBackoffTest {
return AuthService(
accounts,
students,
enrollments,
passwordEncoder,
jwtEncoder,
JwtProperties(accessTokenTtl = Duration.ofMinutes(10)),

View File

@@ -0,0 +1,278 @@
package ru.ysu.lms.identity.promotion
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.springframework.transaction.annotation.Transactional
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.identity.domain.GroupStatus
import ru.ysu.lms.identity.domain.MembershipAction
import ru.ysu.lms.identity.domain.PromotionMode
import ru.ysu.lms.identity.domain.PromotionRunStatus
import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.MembershipHistoryRepository
import ru.ysu.lms.identity.repo.PromotionRunRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.identity.roster.CreateGroupRequest
import ru.ysu.lms.identity.roster.CreateStudentRequest
import ru.ysu.lms.identity.roster.GroupService
import java.time.LocalDate
import java.util.UUID
@SpringBootTest(properties = ["lms.promotion.scheduler-enabled=false"])
@Testcontainers
@Transactional
class PromotionServiceIntegrationTest {
companion object {
@Container
val pg =
PostgreSQLContainer("postgres:18").apply {
withDatabaseName("lms")
withUsername("lms")
withPassword("lms")
}
@JvmStatic
@DynamicPropertySource
fun datasource(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url") { pg.jdbcUrl }
registry.add("spring.datasource.username") { "lms" }
registry.add("spring.datasource.password") { "lms" }
}
}
@Autowired lateinit var promotionService: PromotionService
@Autowired lateinit var groupService: GroupService
@Autowired lateinit var groups: GroupRepository
@Autowired lateinit var students: StudentRepository
@Autowired lateinit var enrollments: EnrollmentRepository
@Autowired lateinit var history: MembershipHistoryRepository
@Autowired lateinit var runs: PromotionRunRepository
@Test
fun `dry run only plans and does not change data`() {
val fixture = fixture()
val plan = promotionService.dryRun(2026, null)
assertEquals("DRY_RUN", plan.mode)
assertEquals("PLANNED", plan.status)
assertEquals(5, plan.items.size)
assertTrue(plan.items.any { it.action == "PROMOTE" && it.entityId == fixture.promoteGroupId.toString() })
assertTrue(plan.items.any { it.action == "PROMOTE" && it.entityId == fixture.academicGroupId.toString() })
assertTrue(plan.items.any { it.action == "GRADUATE" && it.entityId == fixture.graduateGroupId.toString() })
assertTrue(plan.items.any { it.action == "GRADUATE" && it.entityId == fixture.graduateStudentId.toString() })
assertTrue(plan.items.any { it.action == "ACADEMIC" && it.entityId == fixture.academicStudentId.toString() })
val promoteGroup = groups.findById(fixture.promoteGroupId).orElseThrow()
assertEquals(1.toShort(), promoteGroup.course)
assertEquals("ПИ-13БО", promoteGroup.name)
val graduateGroup = groups.findById(fixture.graduateGroupId).orElseThrow()
assertEquals(GroupStatus.ACTIVE, graduateGroup.status)
assertEquals(StudentStatus.ACTIVE, students.findById(fixture.graduateStudentId).orElseThrow().status)
assertEquals(StudentStatus.ACADEMIC_LEAVE, students.findById(fixture.academicStudentId).orElseThrow().status)
assertNull(
groups.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
"ПИ",
"БО",
1,
2,
2025,
),
)
val repeated = promotionService.dryRun(2026, null)
assertEquals(plan.id, repeated.id)
}
@Test
fun `apply promotes groups graduates and moves academic students`() {
val fixture = fixture()
val plan = promotionService.dryRun(2026, null)
val applied = promotionService.apply(UUID.fromString(plan.id), null)
assertEquals("COMPLETED", applied.status)
assertEquals("APPLY", applied.mode)
assertTrue(applied.items.all { it.status == "APPLIED" })
val promoted = groups.findById(fixture.promoteGroupId).orElseThrow()
assertEquals(2.toShort(), promoted.course)
assertEquals("ПИ-23БО", promoted.name)
assertEquals(GroupStatus.ACTIVE, promoted.status)
val graduatedGroup = groups.findById(fixture.graduateGroupId).orElseThrow()
assertEquals(GroupStatus.GRADUATED, graduatedGroup.status)
assertEquals(StudentStatus.GRADUATED, students.findById(fixture.graduateStudentId).orElseThrow().status)
val academic = students.findById(fixture.academicStudentId).orElseThrow()
assertEquals(StudentStatus.ACADEMIC_LEAVE, academic.status)
assertEquals(2025.toShort(), academic.enrollmentYear)
val academicGroup = enrollments.findByStudentAccountId(fixture.academicStudentId)?.group
assertNotNull(academicGroup)
assertEquals("ПИ-21БО", academicGroup!!.name)
assertEquals(2.toShort(), academicGroup.course)
assertEquals(2025.toShort(), academicGroup.enrollmentYear)
assertEquals(GroupStatus.ACTIVE, academicGroup.status)
val promoteEvents = history.findByStudentIdOrderByAtDesc(fixture.promoteStudentId)
assertTrue(promoteEvents.any { it.action == MembershipAction.PROMOTE })
val graduateEvents = history.findByStudentIdOrderByAtDesc(fixture.graduateStudentId)
assertTrue(graduateEvents.any { it.action == MembershipAction.GRADUATE })
val academicEvents = history.findByStudentIdOrderByAtDesc(fixture.academicStudentId)
assertTrue(academicEvents.any { it.action == MembershipAction.ACADEMIC })
}
@Test
fun `repeated apply is idempotent`() {
fixture()
val plan = promotionService.dryRun(2026, null)
val first = promotionService.apply(UUID.fromString(plan.id), null)
val historyBefore = history.count()
val second = promotionService.apply(UUID.fromString(plan.id), null)
assertEquals(first.id, second.id)
assertEquals("COMPLETED", second.status)
assertEquals(historyBefore, history.count())
assertEquals(1, runs.findAll().count { it.year == 2026 && it.mode == PromotionMode.APPLY })
}
@Test
fun `cancel marks planned dry run and allows rebuild`() {
fixture()
val plan = promotionService.dryRun(2026, null)
val cancelled = promotionService.cancel(UUID.fromString(plan.id), null)
assertEquals("FAILED", cancelled.status)
assertEquals("CANCELLED", cancelled.result?.get("status"))
assertNotNull(cancelled.finishedAt)
assertThrows(ConflictException::class.java) {
promotionService.apply(UUID.fromString(plan.id), null)
}
val rebuilt = promotionService.dryRun(2026, null)
assertNotEquals(plan.id, rebuilt.id)
assertEquals("PLANNED", rebuilt.status)
assertEquals(2, runs.findAll().count { it.year == 2026 && it.mode == PromotionMode.DRY_RUN })
}
@Test
fun `rollback restores data and removes created target group`() {
val fixture = fixture()
val plan = promotionService.dryRun(2026, null)
promotionService.apply(UUID.fromString(plan.id), null)
val academicGroupId = enrollments.findByStudentAccountId(fixture.academicStudentId)!!.group!!.id
val rolledBack = promotionService.rollback(UUID.fromString(plan.id), null)
assertEquals("ROLLED_BACK", rolledBack.status)
assertTrue(rolledBack.items.all { it.status == "ROLLED_BACK" })
val promoted = groups.findById(fixture.promoteGroupId).orElseThrow()
assertEquals(1.toShort(), promoted.course)
assertEquals("ПИ-13БО", promoted.name)
assertEquals(GroupStatus.ACTIVE, groups.findById(fixture.graduateGroupId).orElseThrow().status)
assertEquals(StudentStatus.ACTIVE, students.findById(fixture.graduateStudentId).orElseThrow().status)
val academic = students.findById(fixture.academicStudentId).orElseThrow()
assertEquals(StudentStatus.ACADEMIC_LEAVE, academic.status)
assertEquals(2024.toShort(), academic.enrollmentYear)
assertEquals(fixture.academicGroupId, enrollments.findByStudentAccountId(fixture.academicStudentId)!!.group!!.id)
assertTrue(groups.findById(academicGroupId).isEmpty)
assertTrue(history.findByStudentIdOrderByAtDesc(fixture.graduateStudentId).any { it.action == MembershipAction.RETURN })
val historyAfterRollback = history.count()
assertThrows(ConflictException::class.java) {
promotionService.rollback(UUID.fromString(plan.id), null)
}
assertEquals(historyAfterRollback, history.count())
}
@Test
fun `rollback reports manual conflict`() {
val fixture = fixture()
val plan = promotionService.dryRun(2026, null)
promotionService.apply(UUID.fromString(plan.id), null)
val promoted = groups.findById(fixture.promoteGroupId).orElseThrow()
promoted.course = 3
groups.saveAndFlush(promoted)
val error =
assertThrows(ConflictException::class.java) {
promotionService.rollback(UUID.fromString(plan.id), null)
}
assertTrue(error.message.contains("ПИ-23БО"))
assertEquals(PromotionRunStatus.COMPLETED, runs.findById(UUID.fromString(plan.id)).orElseThrow().status)
assertEquals(3.toShort(), groups.findById(fixture.promoteGroupId).orElseThrow().course)
}
@Test
fun `scheduled dry run guard respects date and deduplicates by year`() {
val before = runs.count()
assertNull(promotionService.ensureScheduledDryRun(LocalDate.of(2026, 8, 30)))
assertEquals(before, runs.count())
val created = promotionService.ensureScheduledDryRun(LocalDate.of(2026, 8, 31))
assertNotNull(created)
assertEquals("DRY_RUN", created!!.mode)
assertEquals("PLANNED", created.status)
assertNull(promotionService.ensureScheduledDryRun(LocalDate.of(2026, 9, 15)))
assertEquals(1, runs.findAll().count { it.year == 2026 })
}
private fun fixture(): Fixture {
val promote = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 3, degree = "БО", enrollmentYear = 2025))
val graduate = groupService.create(CreateGroupRequest(stream = "ПИ", course = 4, number = 4, degree = "БО", enrollmentYear = 2022))
val academic = groupService.create(CreateGroupRequest(stream = "ПИ", course = 2, number = 1, degree = "БО", enrollmentYear = 2024))
val promoteStudent = groupService.createStudent(UUID.fromString(promote.id), student("1001", "Иванов"), null)
val graduateStudent = groupService.createStudent(UUID.fromString(graduate.id), student("1002", "Петров"), null)
val academicStudent = groupService.createStudent(UUID.fromString(academic.id), student("1003", "Сидоров"), null)
val academicId = UUID.fromString(academicStudent.id)
val entity = students.findById(academicId).orElseThrow()
entity.status = StudentStatus.ACADEMIC_LEAVE
students.saveAndFlush(entity)
return Fixture(
promoteGroupId = UUID.fromString(promote.id),
graduateGroupId = UUID.fromString(graduate.id),
academicGroupId = UUID.fromString(academic.id),
promoteStudentId = UUID.fromString(promoteStudent.id),
graduateStudentId = UUID.fromString(graduateStudent.id),
academicStudentId = academicId,
)
}
private fun student(
number: String,
lastName: String,
) = CreateStudentRequest(studentNo = number, lastName = lastName, firstName = "Тест")
private data class Fixture(
val promoteGroupId: UUID,
val graduateGroupId: UUID,
val academicGroupId: UUID,
val promoteStudentId: UUID,
val graduateStudentId: UUID,
val academicStudentId: UUID,
)
}

View File

@@ -0,0 +1,384 @@
package ru.ysu.lms.identity.roster
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.springframework.transaction.annotation.Transactional
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.AuthService
import ru.ysu.lms.identity.auth.LoginRequest
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.AccountStatus
import ru.ysu.lms.identity.domain.GroupStatus
import ru.ysu.lms.identity.domain.MembershipAction
import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.AccountRepository
import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.MembershipHistoryRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.LmsRole
import java.util.UUID
@SpringBootTest(properties = ["lms.promotion.scheduler-enabled=false"])
@Testcontainers
@Transactional
class StudentLifecycleIntegrationTest {
companion object {
@Container
val pg =
PostgreSQLContainer("postgres:18").apply {
withDatabaseName("lms")
withUsername("lms")
withPassword("lms")
}
@JvmStatic
@DynamicPropertySource
fun datasource(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url") { pg.jdbcUrl }
registry.add("spring.datasource.username") { "lms" }
registry.add("spring.datasource.password") { "lms" }
}
}
@Autowired lateinit var lifecycle: StudentLifecycleService
@Autowired lateinit var groupService: GroupService
@Autowired lateinit var authService: AuthService
@Autowired lateinit var cohortResolver: GroupCohortResolver
@Autowired lateinit var accounts: AccountRepository
@Autowired lateinit var students: StudentRepository
@Autowired lateinit var groups: GroupRepository
@Autowired lateinit var enrollments: EnrollmentRepository
@Autowired lateinit var history: MembershipHistoryRepository
@Test
fun `academic leave creates target cohort group and records history`() {
val source = createGroup(course = 2, number = 1, enrollmentYear = 2024)
val credential = createStudent(source, "7101", "Иванов", "Иван")
val studentId = UUID.fromString(credential.id)
val expectedYear = cohortResolver.defaultEnrollmentYear(2)
val actor = adminActor()
val result = lifecycle.academicLeave(studentId, StudentReasonRequest("Болезнь"), actor)
val target =
requireNotNull(
groups.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear("ПИ", "БО", 1, 2, expectedYear),
)
assertEquals("ПИ-21БО", target.name)
assertEquals(expectedYear, target.enrollmentYear)
assertEquals(GroupStatus.ACTIVE, target.status)
assertEquals("ACADEMIC", result.action)
assertEquals(target.id.toString(), result.toGroupId)
val student = students.findById(studentId).orElseThrow()
assertEquals(StudentStatus.ACADEMIC_LEAVE, student.status)
assertEquals(expectedYear, student.enrollmentYear)
assertEquals(target.id, enrollments.findByStudentAccountId(studentId)?.group?.id)
val event =
history
.findByStudentIdOrderByAtDesc(studentId)
.first { it.action == MembershipAction.ACADEMIC }
assertEquals(source, event.fromGroupId)
assertEquals(target.id, event.toGroupId)
assertEquals("Болезнь", event.reason)
assertEquals(actor, event.actorAccountId)
}
@Test
fun `academic leave reuses an existing target cohort group`() {
val source = createGroup(course = 2, number = 2, enrollmentYear = 2023)
val expectedYear = cohortResolver.defaultEnrollmentYear(2)
val existing =
groupService.create(
CreateGroupRequest(
stream = "ПИ",
course = 2,
number = 2,
degree = "БО",
enrollmentYear = expectedYear,
),
)
val credential = createStudent(source, "7102", "Петров", "Пётр")
val studentId = UUID.fromString(credential.id)
val result = lifecycle.academicLeave(studentId, null, null)
assertEquals(existing.id, result.toGroupId)
assertEquals(
1,
groups.findAll().count {
it.stream == "ПИ" &&
it.degree == "БО" &&
it.number == 2.toShort() &&
it.course == 2.toShort() &&
it.enrollmentYear == expectedYear
},
)
assertEquals(StudentStatus.ACADEMIC_LEAVE, students.findById(studentId).orElseThrow().status)
}
@Test
fun `return from academic leave moves to requested active group`() {
val source = createGroup(course = 2, number = 3, enrollmentYear = 2024)
val credential = createStudent(source, "7103", "Сидоров", "Сидор")
val studentId = UUID.fromString(credential.id)
lifecycle.academicLeave(studentId, StudentReasonRequest("Семейные обстоятельства"), null)
val target = createGroup(course = 2, number = 4, enrollmentYear = 2025)
val result = lifecycle.returnFromLeave(studentId, StudentGroupRequest(target, "Возврат"), null)
val student = students.findById(studentId).orElseThrow()
assertEquals(StudentStatus.ACTIVE, student.status)
assertEquals(2025.toShort(), student.enrollmentYear)
assertEquals(target, enrollments.findByStudentAccountId(studentId)?.group?.id)
assertEquals("RETURN", result.action)
assertEquals(target.toString(), result.toGroupId)
val event =
history
.findByStudentIdOrderByAtDesc(studentId)
.first { it.action == MembershipAction.RETURN }
assertEquals(target, event.toGroupId)
assertEquals("Возврат", event.reason)
}
@Test
fun `expel blocks login and keeps current enrollment for history`() {
val group = createGroup(course = 1, number = 5, enrollmentYear = 2025)
val credential = createStudent(group, "7104", "Кузнецов", "Кузьма")
val studentId = UUID.fromString(credential.id)
authService.login(LoginRequest(credential.studentNo, credential.password))
lifecycle.expel(studentId, StudentReasonRequest("Нарушение правил"), null)
val error =
runCatching {
authService.login(LoginRequest(credential.studentNo, credential.password))
}.exceptionOrNull()
assertTrue(error is UnauthorizedException)
assertEquals(StudentStatus.EXPELLED, students.findById(studentId).orElseThrow().status)
assertEquals(group, enrollments.findByStudentAccountId(studentId)?.group?.id)
val event =
history
.findByStudentIdOrderByAtDesc(studentId)
.first { it.action == MembershipAction.EXPELL }
assertEquals(group, event.fromGroupId)
assertEquals("Нарушение правил", event.reason)
}
@Test
fun `student in archived group cannot login`() {
val group = createGroup(course = 1, number = 12, enrollmentYear = 2025)
val credential = createStudent(group, "7110", "Архипов", "Артём")
authService.login(LoginRequest(credential.studentNo, credential.password))
groupService.archiveGroup(group)
val error =
runCatching {
authService.login(LoginRequest(credential.studentNo, credential.password))
}.exceptionOrNull()
assertTrue(error is UnauthorizedException)
assertEquals("Группа закрыта", error?.message)
}
@Test
fun `student in active group can login`() {
val group = createGroup(course = 1, number = 13, enrollmentYear = 2025)
val credential = createStudent(group, "7111", "Активов", "Антон")
val response = authService.login(LoginRequest(credential.studentNo, credential.password))
assertEquals("STUDENT", response.role)
}
@Test
fun `student without group can login`() {
val group = createGroup(course = 1, number = 14, enrollmentYear = 2025)
val credential = createStudent(group, "7112", "Свободов", "Семён")
val studentId = UUID.fromString(credential.id)
enrollments.findByStudentAccountId(studentId)?.let { enrollments.delete(it) }
enrollments.flush()
val response = authService.login(LoginRequest(credential.studentNo, credential.password))
assertEquals("STUDENT", response.role)
}
@Test
fun `restore returns an expelled student to active group`() {
val group = createGroup(course = 1, number = 10, enrollmentYear = 2025)
val credential = createStudent(group, "7109", "Соколов", "Семён")
val studentId = UUID.fromString(credential.id)
lifecycle.expel(studentId, StudentReasonRequest("Отчисление"), null)
val target = createGroup(course = 1, number = 11, enrollmentYear = 2025)
val result =
lifecycle.restore(
studentId,
StudentGroupRequest(target, "Восстановление после отчисления"),
null,
)
val student = students.findById(studentId).orElseThrow()
assertEquals(StudentStatus.ACTIVE, student.status)
assertEquals(2025.toShort(), student.enrollmentYear)
assertEquals(target, enrollments.findByStudentAccountId(studentId)?.group?.id)
val account = accounts.findById(studentId).orElseThrow()
assertEquals(AccountStatus.ACTIVE, account.status)
assertNull(account.archivedAt)
assertEquals("RESTORE", result.action)
val event =
history
.findByStudentIdOrderByAtDesc(studentId)
.first { it.action == MembershipAction.RESTORE }
assertEquals(group, event.fromGroupId)
assertEquals(target, event.toGroupId)
assertEquals("Восстановление после отчисления", event.reason)
}
@Test
fun `archive blocks login and restore returns student to active group`() {
val group = createGroup(course = 1, number = 6, enrollmentYear = 2025)
val credential = createStudent(group, "7105", "Морозов", "Марк")
val studentId = UUID.fromString(credential.id)
authService.login(LoginRequest(credential.studentNo, credential.password))
lifecycle.archive(studentId, null)
assertEquals(StudentStatus.ARCHIVED, students.findById(studentId).orElseThrow().status)
val archived = accounts.findById(studentId).orElseThrow()
assertEquals(AccountStatus.ARCHIVED, archived.status)
assertNotNull(archived.archivedAt)
val error =
runCatching {
authService.login(LoginRequest(credential.studentNo, credential.password))
}.exceptionOrNull()
assertTrue(error is UnauthorizedException)
val target = createGroup(course = 1, number = 7, enrollmentYear = 2025)
val result = lifecycle.restore(studentId, StudentGroupRequest(target, "Восстановление"), null)
val restored = accounts.findById(studentId).orElseThrow()
assertEquals(AccountStatus.ACTIVE, restored.status)
assertNull(restored.archivedAt)
val student = students.findById(studentId).orElseThrow()
assertEquals(StudentStatus.ACTIVE, student.status)
assertEquals(2025.toShort(), student.enrollmentYear)
assertEquals(target, enrollments.findByStudentAccountId(studentId)?.group?.id)
assertEquals("RESTORE", result.action)
val event =
history
.findByStudentIdOrderByAtDesc(studentId)
.first { it.action == MembershipAction.RESTORE }
assertEquals(target, event.toGroupId)
assertEquals("Восстановление", event.reason)
}
@Test
fun `group archive changes only group status`() {
val group = createGroup(course = 1, number = 8, enrollmentYear = 2025)
val credential = createStudent(group, "7106", "Волков", "Владимир")
val studentId = UUID.fromString(credential.id)
val detail = groupService.archiveGroup(group)
assertEquals("ARCHIVED", detail.status)
assertEquals(1, detail.students.size)
assertEquals(GroupStatus.ARCHIVED, groups.findById(group).orElseThrow().status)
assertEquals(StudentStatus.ACTIVE, students.findById(studentId).orElseThrow().status)
assertThrows(BadRequestException::class.java) { groupService.archiveGroup(group) }
}
@Test
fun `delete requires confirm equal to login or full name`() {
val group = createGroup(course = 1, number = 9, enrollmentYear = 2025)
val first = createStudent(group, "7107", "Иванов", "Иван")
val firstId = UUID.fromString(first.id)
assertThrows(BadRequestException::class.java) {
groupService.deleteUser(firstId, UUID.randomUUID(), "неверно")
}
assertTrue(accounts.findById(firstId).isPresent)
assertTrue(students.findById(firstId).isPresent)
val deleted = groupService.deleteUser(firstId, UUID.randomUUID(), "7107")
assertEquals("7107", deleted.login)
assertTrue(accounts.findById(firstId).isEmpty)
assertTrue(students.findById(firstId).isEmpty)
assertNull(enrollments.findByStudentAccountId(firstId))
val second = createStudent(group, "7108", "Петров", "Пётр")
val secondId = UUID.fromString(second.id)
groupService.deleteUser(secondId, UUID.randomUUID(), "Петров Пётр")
assertTrue(accounts.findById(secondId).isEmpty)
assertTrue(students.findById(secondId).isEmpty)
assertFalse(students.existsById(secondId))
}
private fun createGroup(
course: Short,
number: Short,
enrollmentYear: Short,
): UUID {
val created =
groupService.create(
CreateGroupRequest(
stream = "ПИ",
course = course,
number = number,
degree = "БО",
enrollmentYear = enrollmentYear,
),
)
return UUID.fromString(created.id)
}
private fun createStudent(
groupId: UUID,
studentNo: String,
lastName: String,
firstName: String,
) = groupService.createStudent(
groupId,
CreateStudentRequest(studentNo = studentNo, lastName = lastName, firstName = firstName),
null,
)
private fun adminActor(): UUID {
val account =
Account(
login = "admin-${UUID.randomUUID()}",
passwordHash = "test",
role = LmsRole.ADMIN,
mustChangePassword = false,
)
return accounts.saveAndFlush(account).id
}
}