fix: закрыть P0/P1 расхождения с целевой моделью
- академический отпуск: readOnly, запрет submit/start/finish защиты - черновики лабораторных скрыты от студентов в списке/деталях/по UUID - удалено действие «Убрать из группы» из UI и API - импорт: restoreInactiveStatuses, skipped-отчёт, разблокировка архивного аккаунта - глобальные материалы STUDENTS/TEACHERS с JWT-фильтрацией и сменой аудитории - LDD-AUTH-005 переведён на unit-evidence, clean check проходит по P0 - backup: безопасный парсинг .env, verify-latest, кавычки cron - robots/noindex, комментарии без автора, профиль без черновиков - docs/18-goal-audit.md; baseline 57 claims / 366 тестов / release PASS
This commit is contained in:
@@ -41,6 +41,7 @@ data class AuthResponse(
|
||||
val mustChangePassword: Boolean,
|
||||
val refreshToken: String? = null,
|
||||
val accessTokenExpiresIn: Long = 0,
|
||||
val readOnly: Boolean = false,
|
||||
)
|
||||
|
||||
data class MeResponse(
|
||||
@@ -50,4 +51,5 @@ data class MeResponse(
|
||||
val name: String,
|
||||
val mustChangePassword: Boolean,
|
||||
val active: Boolean = true,
|
||||
val readOnly: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -94,6 +94,11 @@ class AuthService(
|
||||
loginDeniedReason(account)?.let { throw UnauthorizedException(it) }
|
||||
}
|
||||
|
||||
/** Академический отпуск: вход разрешён, но писать данные нельзя. */
|
||||
private fun readOnly(account: Account): Boolean =
|
||||
account.role == LmsRole.STUDENT &&
|
||||
students.findById(account.id).map { it.status == StudentStatus.ACADEMIC_LEAVE }.orElse(false)
|
||||
|
||||
private fun ensureLoginAllowed(account: Account) {
|
||||
loginDeniedReason(account)?.let { throw UnauthorizedException(it) }
|
||||
}
|
||||
@@ -129,6 +134,7 @@ class AuthService(
|
||||
name = displayName(account),
|
||||
mustChangePassword = account.mustChangePassword,
|
||||
active = loginDeniedReason(account) == null,
|
||||
readOnly = readOnly(account),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -189,6 +195,7 @@ class AuthService(
|
||||
mustChangePassword = account.mustChangePassword,
|
||||
refreshToken = refreshToken,
|
||||
accessTokenExpiresIn = jwtProperties.accessTokenTtl.seconds,
|
||||
readOnly = readOnly(account),
|
||||
)
|
||||
|
||||
private fun issueToken(
|
||||
|
||||
@@ -7,7 +7,6 @@ import org.springframework.http.ResponseEntity
|
||||
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
|
||||
@@ -37,24 +36,62 @@ class GroupController(
|
||||
@PostMapping
|
||||
fun create(
|
||||
@Valid @RequestBody body: CreateGroupRequest,
|
||||
): GroupSummary = groupService.create(body)
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): GroupSummary =
|
||||
groupService.create(body).also {
|
||||
auditService.record(
|
||||
jwt.toAuditRequest(
|
||||
action = "GROUP_CREATE",
|
||||
entityType = "GROUP",
|
||||
entityId = it.id,
|
||||
details = "name=${it.name}",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/students")
|
||||
fun createStudent(
|
||||
@PathVariable id: UUID,
|
||||
@Valid @RequestBody body: CreateStudentRequest,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): Credential = groupService.createStudent(id, body, UUID.fromString(jwt.subject))
|
||||
): Credential =
|
||||
groupService.createStudent(id, body, UUID.fromString(jwt.subject)).also {
|
||||
auditService.record(
|
||||
jwt.toAuditRequest(
|
||||
action = "STUDENT_CREATE",
|
||||
entityType = "STUDENT",
|
||||
entityId = it.id,
|
||||
studentId = it.id,
|
||||
details = "group=$id, studentNo=${it.studentNo}",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@PostMapping("/import")
|
||||
fun import(
|
||||
@RequestParam("file") file: MultipartFile,
|
||||
@RequestParam(defaultValue = "false") restoreInactiveStatuses: Boolean,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): ImportResult {
|
||||
if (file.isEmpty) {
|
||||
throw BadRequestException("Выберите файл со списком группы")
|
||||
}
|
||||
return importService.import(file.originalFilename ?: "group.xlsx", file.bytes, UUID.fromString(jwt.subject))
|
||||
val result =
|
||||
importService.import(
|
||||
file.originalFilename ?: "group.xlsx",
|
||||
file.bytes,
|
||||
UUID.fromString(jwt.subject),
|
||||
restoreInactiveStatuses,
|
||||
)
|
||||
auditService.record(
|
||||
jwt.toAuditRequest(
|
||||
action = "GROUP_IMPORT",
|
||||
entityType = "GROUP",
|
||||
entityId = result.groupId,
|
||||
details = "added=${result.added.size}, updated=${result.updated.size}, skipped=${result.skipped.size}",
|
||||
),
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */
|
||||
@@ -69,15 +106,18 @@ class GroupController(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable studentId: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): GroupDetail = groupService.enroll(id, studentId, UUID.fromString(jwt.subject))
|
||||
|
||||
/** Отчислить студента из группы. */
|
||||
@DeleteMapping("/{id}/students/{studentId}")
|
||||
fun unenroll(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable studentId: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): GroupDetail = groupService.unenroll(id, studentId, UUID.fromString(jwt.subject))
|
||||
): GroupDetail =
|
||||
groupService.enroll(id, studentId, UUID.fromString(jwt.subject)).also {
|
||||
auditService.record(
|
||||
jwt.toAuditRequest(
|
||||
action = "GROUP_ENROLL",
|
||||
entityType = "STUDENT",
|
||||
entityId = studentId.toString(),
|
||||
studentId = studentId.toString(),
|
||||
details = "group=$id",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
|
||||
@@ -6,6 +6,7 @@ import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.common.ConflictException
|
||||
import ru.ysu.lms.identity.auth.Passwords
|
||||
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
|
||||
@@ -37,6 +38,7 @@ class GroupImportService(
|
||||
filename: String,
|
||||
bytes: ByteArray,
|
||||
actorAccountId: UUID?,
|
||||
restoreInactiveStatuses: Boolean = false,
|
||||
): ImportResult {
|
||||
val parsed = parser.parse(filename, bytes)
|
||||
if (parsed.errors.isNotEmpty()) {
|
||||
@@ -56,10 +58,22 @@ class GroupImportService(
|
||||
val group = findOrCreateGroup(cohort)
|
||||
val added = mutableListOf<Credential>()
|
||||
val updated = mutableListOf<String>()
|
||||
val skipped = mutableListOf<ImportError>()
|
||||
|
||||
parsed.students.forEach { parsedStudent ->
|
||||
val student = upsertStudent(parsedStudent, cohort, added, updated)
|
||||
moveToGroup(student, group, actorAccountId)
|
||||
val student = upsertStudent(parsedStudent, cohort, added, updated, restoreInactiveStatuses)
|
||||
if (student.status == StudentStatus.ACTIVE) {
|
||||
moveToGroup(student, group, actorAccountId)
|
||||
} else {
|
||||
skipped +=
|
||||
ImportError(
|
||||
row = parsedStudent.row,
|
||||
message =
|
||||
"Студент ${parsedStudent.studentNo} в статусе ${student.status.name}: " +
|
||||
"статус и группа не изменены. Для восстановления включите явное восстановление " +
|
||||
"отчисленных/выпущенных/архивных/академических студентов.",
|
||||
)
|
||||
}
|
||||
}
|
||||
return ImportResult(
|
||||
applied = true,
|
||||
@@ -67,6 +81,7 @@ class GroupImportService(
|
||||
groupName = group.name,
|
||||
added = added,
|
||||
updated = updated,
|
||||
skipped = skipped,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -103,6 +118,7 @@ class GroupImportService(
|
||||
cohort: GroupCohort,
|
||||
added: MutableList<Credential>,
|
||||
updated: MutableList<String>,
|
||||
restoreInactiveStatuses: Boolean,
|
||||
): Student {
|
||||
val existing = students.findByStudentNo(parsed.studentNo)
|
||||
if (existing != null) {
|
||||
@@ -111,8 +127,15 @@ class GroupImportService(
|
||||
existing.middleName = parsed.middleName
|
||||
existing.basis = parsed.basis
|
||||
existing.note = parsed.note
|
||||
existing.status = StudentStatus.ACTIVE
|
||||
existing.enrollmentYear = cohort.enrollmentYear
|
||||
if (existing.status == StudentStatus.ACTIVE || restoreInactiveStatuses) {
|
||||
existing.status = StudentStatus.ACTIVE
|
||||
existing.enrollmentYear = cohort.enrollmentYear
|
||||
existing.account?.takeIf { it.status == AccountStatus.ARCHIVED }?.let { account ->
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account.archivedAt = null
|
||||
accounts.save(account)
|
||||
}
|
||||
}
|
||||
students.save(existing)
|
||||
updated += parsed.studentNo
|
||||
return existing
|
||||
|
||||
@@ -172,26 +172,6 @@ class GroupService(
|
||||
return detail(groupId)
|
||||
}
|
||||
|
||||
/** Убрать студента из группы; аккаунт и данные сохраняются. */
|
||||
@Transactional
|
||||
fun unenroll(
|
||||
groupId: UUID,
|
||||
studentId: UUID,
|
||||
actorAccountId: UUID? = null,
|
||||
): GroupDetail {
|
||||
val enrollment =
|
||||
enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
|
||||
?: throw NotFoundException("Студент не входит в эту группу")
|
||||
enrollments.delete(enrollment)
|
||||
val student = students.findById(studentId).orElse(null)
|
||||
if (student != null) {
|
||||
student.status = StudentStatus.EXPELLED
|
||||
students.save(student)
|
||||
}
|
||||
recordHistory(studentId, groupId, null, MembershipAction.EXPELL, actorAccountId)
|
||||
return detail(groupId)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun groupOfStudent(accountId: UUID): GroupSummary? {
|
||||
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null
|
||||
|
||||
@@ -72,6 +72,7 @@ data class ImportResult(
|
||||
val groupName: String? = null,
|
||||
val added: List<Credential> = emptyList(),
|
||||
val updated: List<String> = emptyList(),
|
||||
val skipped: List<ImportError> = emptyList(),
|
||||
val errors: List<ImportError> = emptyList(),
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ data class ParsedStudent(
|
||||
val middleName: String,
|
||||
val basis: String,
|
||||
val note: String,
|
||||
val row: Int = 0,
|
||||
) {
|
||||
val fullName: String
|
||||
get() = listOf(lastName, firstName, middleName).filter { it.isNotBlank() }.joinToString(" ")
|
||||
@@ -300,6 +301,7 @@ class StudentImportParser {
|
||||
middleName = middleName,
|
||||
basis = cell(row, columns, "basis"),
|
||||
note = cell(row, columns, "note"),
|
||||
row = rowNumber,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ class GroupServiceIntegrationTest {
|
||||
|
||||
@Autowired lateinit var importService: GroupImportService
|
||||
|
||||
@Autowired lateinit var lifecycleService: StudentLifecycleService
|
||||
|
||||
@Autowired lateinit var authService: AuthService
|
||||
|
||||
@Autowired lateinit var groups: GroupRepository
|
||||
@@ -119,7 +121,7 @@ class GroupServiceIntegrationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transfer and expulsion keep history`() {
|
||||
fun `transfer keeps history`() {
|
||||
val first = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 1, degree = "БО", enrollmentYear = 2025))
|
||||
val second = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 2, degree = "БО", enrollmentYear = 2025))
|
||||
val imported = importService.import("group.csv", csv(validRows()), null)
|
||||
@@ -137,9 +139,6 @@ class GroupServiceIntegrationTest {
|
||||
)
|
||||
assertEquals(MembershipAction.TRANSFER, history.findByStudentIdOrderByAtDesc(studentId).first().action)
|
||||
|
||||
groupService.unenroll(UUID.fromString(second.id), studentId, null)
|
||||
assertEquals(StudentStatus.EXPELLED, students.findById(studentId).orElseThrow().status)
|
||||
assertEquals(MembershipAction.EXPELL, history.findByStudentIdOrderByAtDesc(studentId).first().action)
|
||||
assertEquals(1, groups.findAll().count { it.id == UUID.fromString(first.id) })
|
||||
assertTrue(imported.applied)
|
||||
}
|
||||
@@ -155,12 +154,118 @@ class GroupServiceIntegrationTest {
|
||||
)
|
||||
authService.login(LoginRequest("9001", credential.password))
|
||||
|
||||
groupService.unenroll(UUID.fromString(group.id), UUID.fromString(credential.id), null)
|
||||
lifecycleService.expel(UUID.fromString(credential.id), null, null)
|
||||
val error = runCatching { authService.login(LoginRequest("9001", credential.password)) }.exceptionOrNull()
|
||||
|
||||
assertTrue(error is UnauthorizedException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `import does not resurrect expelled student without explicit flag`() {
|
||||
importService.import("group.csv", csv(validRows()), null)
|
||||
val student = students.findByStudentNo("2001")!!
|
||||
val studentId = student.accountId!!
|
||||
val originalGroupId = enrollments.findByStudentAccountId(studentId)?.group?.id
|
||||
student.status = StudentStatus.EXPELLED
|
||||
students.saveAndFlush(student)
|
||||
|
||||
val result =
|
||||
importService.import(
|
||||
"group.csv",
|
||||
csv("ПИ;2;2;БО;2024;Иванов Иван;2001;контракт"),
|
||||
null,
|
||||
)
|
||||
|
||||
val preserved = students.findById(studentId).orElseThrow()
|
||||
assertTrue(result.applied)
|
||||
assertEquals(StudentStatus.EXPELLED, preserved.status)
|
||||
assertEquals(originalGroupId, enrollments.findByStudentAccountId(studentId)?.group?.id)
|
||||
assertEquals(2025.toShort(), preserved.enrollmentYear)
|
||||
assertEquals("контракт", preserved.basis)
|
||||
assertEquals(1, result.skipped.size)
|
||||
val skipped = result.skipped.single()
|
||||
assertEquals(2, skipped.row)
|
||||
assertTrue(skipped.message.contains("EXPELLED"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `import restores expelled student with explicit flag and records import history`() {
|
||||
importService.import("group.csv", csv(validRows()), null)
|
||||
val student = students.findByStudentNo("2001")!!
|
||||
val studentId = student.accountId!!
|
||||
student.status = StudentStatus.EXPELLED
|
||||
students.saveAndFlush(student)
|
||||
|
||||
val result =
|
||||
importService.import(
|
||||
"group.csv",
|
||||
csv("ПИ;2;2;БО;2024;Иванов Иван;2001;контракт"),
|
||||
null,
|
||||
restoreInactiveStatuses = true,
|
||||
)
|
||||
|
||||
val restored = students.findById(studentId).orElseThrow()
|
||||
val target = groups.findByName("ПИ-22БО")!!
|
||||
assertTrue(result.applied)
|
||||
assertTrue(result.skipped.isEmpty())
|
||||
assertEquals(StudentStatus.ACTIVE, restored.status)
|
||||
assertEquals(2024.toShort(), restored.enrollmentYear)
|
||||
assertEquals(target.id, enrollments.findByStudentAccountId(studentId)?.group?.id)
|
||||
assertEquals(MembershipAction.IMPORT, history.findByStudentIdOrderByAtDesc(studentId).first().action)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `import preserves academic leave without explicit flag`() {
|
||||
importService.import("group.csv", csv(validRows()), null)
|
||||
val student = students.findByStudentNo("2001")!!
|
||||
val studentId = student.accountId!!
|
||||
val originalGroupId = enrollments.findByStudentAccountId(studentId)?.group?.id
|
||||
student.status = StudentStatus.ACADEMIC_LEAVE
|
||||
students.saveAndFlush(student)
|
||||
|
||||
val result =
|
||||
importService.import(
|
||||
"group.csv",
|
||||
csv("ПИ;2;2;БО;2024;Иванов Иван;2001;контракт"),
|
||||
null,
|
||||
)
|
||||
|
||||
val preserved = students.findById(studentId).orElseThrow()
|
||||
assertTrue(result.applied)
|
||||
assertEquals(StudentStatus.ACADEMIC_LEAVE, preserved.status)
|
||||
assertEquals(originalGroupId, enrollments.findByStudentAccountId(studentId)?.group?.id)
|
||||
assertEquals(2025.toShort(), preserved.enrollmentYear)
|
||||
assertEquals(1, result.skipped.size)
|
||||
val skipped = result.skipped.single()
|
||||
assertEquals(2, skipped.row)
|
||||
assertTrue(skipped.message.contains("ACADEMIC_LEAVE"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `import still updates an active student`() {
|
||||
importService.import("group.csv", csv(validRows()), null)
|
||||
val student = students.findByStudentNo("2001")!!
|
||||
val studentId = student.accountId!!
|
||||
|
||||
val result =
|
||||
importService.import(
|
||||
"group.csv",
|
||||
csv("ПИ;2;2;БО;2024;Иванов Иван Иванович;2001;контракт"),
|
||||
null,
|
||||
)
|
||||
|
||||
val updated = students.findById(studentId).orElseThrow()
|
||||
val target = groups.findByName("ПИ-22БО")!!
|
||||
assertTrue(result.applied)
|
||||
assertTrue(result.skipped.isEmpty())
|
||||
assertTrue(result.updated.contains("2001"))
|
||||
assertEquals(StudentStatus.ACTIVE, updated.status)
|
||||
assertEquals("Иванов Иван Иванович", updated.fullName)
|
||||
assertEquals("контракт", updated.basis)
|
||||
assertEquals(2024.toShort(), updated.enrollmentYear)
|
||||
assertEquals(target.id, enrollments.findByStudentAccountId(studentId)?.group?.id)
|
||||
}
|
||||
|
||||
private fun validRows(): String =
|
||||
"""
|
||||
ПИ;2;1;БО;2025;Иванов Иван;2001;бюджет
|
||||
|
||||
@@ -20,6 +20,8 @@ class StudentImportParserTest {
|
||||
assertEquals("Иван", result.students[0].firstName)
|
||||
assertEquals("Иванович", result.students[0].middleName)
|
||||
assertEquals("1001", result.students[0].studentNo)
|
||||
assertEquals(2, result.students[0].row)
|
||||
assertEquals(3, result.students[1].row)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -95,6 +95,8 @@ class StudentLifecycleIntegrationTest {
|
||||
val student = students.findById(studentId).orElseThrow()
|
||||
assertEquals(StudentStatus.ACADEMIC_LEAVE, student.status)
|
||||
assertEquals(expectedYear, student.enrollmentYear)
|
||||
assertTrue(authService.me(studentId).readOnly)
|
||||
assertTrue(authService.login(LoginRequest(credential.studentNo, credential.password)).readOnly)
|
||||
assertEquals(target.id, enrollments.findByStudentAccountId(studentId)?.group?.id)
|
||||
|
||||
val event =
|
||||
@@ -140,6 +142,18 @@ class StudentLifecycleIntegrationTest {
|
||||
assertEquals(StudentStatus.ACADEMIC_LEAVE, students.findById(studentId).orElseThrow().status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `academic leave login is read only`() {
|
||||
val group = createGroup(course = 1, number = 6, enrollmentYear = 2025)
|
||||
val credential = createStudent(group, "7105", "Орлов", "Олег")
|
||||
val studentId = UUID.fromString(credential.id)
|
||||
lifecycle.academicLeave(studentId, null, null)
|
||||
|
||||
val login = authService.login(LoginRequest(credential.studentNo, credential.password))
|
||||
assertTrue(login.readOnly)
|
||||
assertTrue(authService.me(studentId).readOnly)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `return from academic leave moves to requested active group`() {
|
||||
val source = createGroup(course = 2, number = 3, enrollmentYear = 2024)
|
||||
|
||||
Reference in New Issue
Block a user