Admin: users page with search and full cascade delete (identity + progress + files)
This commit is contained in:
@@ -20,7 +20,7 @@ spring:
|
||||
- id: progress
|
||||
uri: ${LMS_PROGRESS_URI:http://localhost:8083}
|
||||
predicates:
|
||||
- Path=/api/v1/assign,/api/v1/assign/**,/api/v1/defenses,/api/v1/defenses/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/**,/api/v1/grade-publications/**,/api/v1/comment-templates,/api/v1/submissions/**
|
||||
- Path=/api/v1/assign,/api/v1/assign/**,/api/v1/defenses,/api/v1/defenses/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/**,/api/v1/grade-publications/**,/api/v1/comment-templates,/api/v1/purge,/api/v1/purge/**,/api/v1/submissions/**
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
|
||||
@@ -262,6 +262,63 @@ class GroupService(
|
||||
return GroupDetail(group.id.toString(), group.name, group.taskDurationMin, rows)
|
||||
}
|
||||
|
||||
/** Список всех пользователей (студентов) с фильтром по подстроке. */
|
||||
@Transactional(readOnly = true)
|
||||
fun users(query: String?): List<AdminUser> {
|
||||
val q = query?.trim()?.lowercase().orEmpty()
|
||||
val enrollByStudent =
|
||||
enrollments
|
||||
.findAll()
|
||||
.mapNotNull { e ->
|
||||
val sid = e.student?.accountId
|
||||
val group = e.group
|
||||
if (sid != null && group != null) sid to (group.id to group.name) else null
|
||||
}.toMap()
|
||||
return students
|
||||
.findAll()
|
||||
.mapNotNull { s ->
|
||||
val account = s.account ?: return@mapNotNull null
|
||||
val groupInfo = s.accountId?.let { enrollByStudent[it] }
|
||||
AdminUser(
|
||||
id = account.id.toString(),
|
||||
login = account.login,
|
||||
role = account.role.name,
|
||||
studentNo = s.studentNo,
|
||||
fullName = s.fullName,
|
||||
basis = s.basis,
|
||||
groupId = groupInfo?.first?.toString(),
|
||||
groupName = groupInfo?.second,
|
||||
)
|
||||
}.filter { user ->
|
||||
q.isEmpty() ||
|
||||
user.fullName.lowercase().contains(q) ||
|
||||
user.login.lowercase().contains(q) ||
|
||||
user.studentNo.lowercase().contains(q) ||
|
||||
(user.groupName?.lowercase()?.contains(q) == true)
|
||||
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
|
||||
}
|
||||
|
||||
/** Удаляет студента из identity: зачисление, профиль, аккаунт. */
|
||||
@Transactional
|
||||
fun deleteUser(
|
||||
accountId: UUID,
|
||||
currentAccountId: UUID,
|
||||
): DeleteUserResult {
|
||||
if (accountId == currentAccountId) {
|
||||
throw BadRequestException("Нельзя удалить собственный аккаунт")
|
||||
}
|
||||
val account = accounts.findById(accountId).orElseThrow { NotFoundException("Пользователь не найден") }
|
||||
if (account.role != LmsRole.STUDENT) {
|
||||
throw BadRequestException("Удалять можно только студенческие аккаунты")
|
||||
}
|
||||
val student = students.findById(accountId).orElse(null)
|
||||
val fullName = student?.fullName.orEmpty()
|
||||
enrollments.findByStudentAccountId(accountId)?.let { enrollments.delete(it) }
|
||||
if (student != null) students.delete(student)
|
||||
accounts.delete(account)
|
||||
return DeleteUserResult(accountId.toString(), account.login, fullName)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun resetPassword(studentAccountId: UUID): ResetPasswordResponse {
|
||||
val student =
|
||||
|
||||
@@ -67,6 +67,24 @@ data class StudentCandidate(
|
||||
val currentGroupName: String?,
|
||||
)
|
||||
|
||||
/** Пользователь для страницы управления: аккаунт + профиль студента. */
|
||||
data class AdminUser(
|
||||
val id: String,
|
||||
val login: String,
|
||||
val role: String,
|
||||
val studentNo: String,
|
||||
val fullName: String,
|
||||
val basis: String,
|
||||
val groupId: String?,
|
||||
val groupName: String?,
|
||||
)
|
||||
|
||||
data class DeleteUserResult(
|
||||
val id: String,
|
||||
val login: String,
|
||||
val fullName: String,
|
||||
)
|
||||
|
||||
class ExportFile(
|
||||
val bytes: ByteArray,
|
||||
val filename: String,
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package ru.ysu.lms.identity.roster
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
|
||||
@@ -17,4 +22,17 @@ class StudentController(
|
||||
fun resetPassword(
|
||||
@PathVariable id: UUID,
|
||||
): ResetPasswordResponse = groupService.resetPassword(id)
|
||||
|
||||
/** Поиск по всем пользователям (для страницы управления). */
|
||||
@GetMapping
|
||||
fun list(
|
||||
@RequestParam(required = false, name = "q") query: String?,
|
||||
): List<AdminUser> = groupService.users(query)
|
||||
|
||||
/** Полное удаление пользователя из identity. */
|
||||
@DeleteMapping("/{id}")
|
||||
fun delete(
|
||||
@PathVariable id: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): DeleteUserResult = groupService.deleteUser(id, UUID.fromString(jwt.subject))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/purge")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
class StudentPurgeController(
|
||||
private val service: StudentPurgeService,
|
||||
) {
|
||||
/** Полное удаление данных студента из прогресс-сервиса (включая файлы сдач). */
|
||||
@DeleteMapping("/students/{studentId}")
|
||||
fun purgeStudent(
|
||||
@PathVariable studentId: UUID,
|
||||
): Map<String, Boolean> {
|
||||
service.purge(studentId)
|
||||
return mapOf("ok" to true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.progress.repo.DefenseSessionRepository
|
||||
import ru.ysu.lms.progress.repo.GradeCommentRepository
|
||||
import ru.ysu.lms.progress.repo.GradePublicationStudentRepository
|
||||
import ru.ysu.lms.progress.repo.LabGradeRepository
|
||||
import ru.ysu.lms.progress.repo.StudentLabLevelRepository
|
||||
import ru.ysu.lms.progress.repo.SubmissionRepository
|
||||
import ru.ysu.lms.progress.repo.VariantAssignmentRepository
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Service
|
||||
class StudentPurgeService(
|
||||
private val submissions: SubmissionRepository,
|
||||
private val grades: LabGradeRepository,
|
||||
private val gradeComments: GradeCommentRepository,
|
||||
private val defenseSessions: DefenseSessionRepository,
|
||||
private val assignments: VariantAssignmentRepository,
|
||||
private val levels: StudentLabLevelRepository,
|
||||
private val publicationsSt: GradePublicationStudentRepository,
|
||||
private val storage: SubmissionStorage,
|
||||
) {
|
||||
/** Удаляет все данные студента в прогресс-сервисе: сдач, оценки, комментарии, защиты. */
|
||||
@Transactional
|
||||
fun purgeDb(studentId: UUID) {
|
||||
submissions.findByStudentIdOrderByCreatedAtDesc(studentId).also { submissions.deleteAll(it) }
|
||||
grades.findByStudentId(studentId).also { grades.deleteAll(it) }
|
||||
gradeComments.findByStudentId(studentId).also { gradeComments.deleteAll(it) }
|
||||
defenseSessions.findByStudentId(studentId).also { defenseSessions.deleteAll(it) }
|
||||
assignments.findByStudentId(studentId).also { assignments.deleteAll(it) }
|
||||
levels.findByStudentId(studentId).also { levels.deleteAll(it) }
|
||||
publicationsSt.findByStudentId(studentId).also { publicationsSt.deleteAll(it) }
|
||||
}
|
||||
|
||||
fun purgeFiles(studentId: UUID) = storage.deleteStudentFiles(studentId)
|
||||
|
||||
fun purge(studentId: UUID) {
|
||||
purgeDb(studentId)
|
||||
purgeFiles(studentId)
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,22 @@ class SubmissionStorage(
|
||||
seq: Int,
|
||||
): Path = rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString())
|
||||
|
||||
/** Удаляет всю папку сдач студента (data/submissions/<studentId>/...). */
|
||||
fun deleteStudentFiles(studentId: UUID) {
|
||||
val dir = rootDir.resolve(studentId.toString()).normalize()
|
||||
require(dir != rootDir && dir.startsWith(rootDir)) { "path escape blocked" }
|
||||
if (!Files.exists(dir)) return
|
||||
Files
|
||||
.walk(dir)
|
||||
.use { walk ->
|
||||
walk
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.forEach { p ->
|
||||
runCatching { Files.delete(p) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitize(name: String): String {
|
||||
val base = name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "file" }
|
||||
val clean = base.replace(Regex("[^A-Za-z0-9._\\-\\p{IsCyrillic}]"), "_").take(120)
|
||||
|
||||
@@ -21,4 +21,6 @@ interface GradePublicationStudentRepository : JpaRepository<GradePublicationStud
|
||||
labId: UUID,
|
||||
studentId: UUID,
|
||||
): Boolean
|
||||
|
||||
fun findByStudentId(studentId: UUID): List<GradePublicationStudent>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user