diff --git a/services/gateway/src/main/resources/application.yml b/services/gateway/src/main/resources/application.yml index 748e1db..c9c3120 100644 --- a/services/gateway/src/main/resources/application.yml +++ b/services/gateway/src/main/resources/application.yml @@ -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: diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt index 09f569a..ff81852 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt @@ -262,6 +262,63 @@ class GroupService( return GroupDetail(group.id.toString(), group.name, group.taskDurationMin, rows) } + /** Список всех пользователей (студентов) с фильтром по подстроке. */ + @Transactional(readOnly = true) + fun users(query: String?): List { + 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 = diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt index f153f63..c780281 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt @@ -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, diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentController.kt index db9f840..44aa75d 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentController.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentController.kt @@ -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 = groupService.users(query) + + /** Полное удаление пользователя из identity. */ + @DeleteMapping("/{id}") + fun delete( + @PathVariable id: UUID, + @AuthenticationPrincipal jwt: Jwt, + ): DeleteUserResult = groupService.deleteUser(id, UUID.fromString(jwt.subject)) } diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/StudentPurgeController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/StudentPurgeController.kt new file mode 100644 index 0000000..d62ee9d --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/StudentPurgeController.kt @@ -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 { + service.purge(studentId) + return mapOf("ok" to true) + } +} diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/StudentPurgeService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/StudentPurgeService.kt new file mode 100644 index 0000000..efc0913 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/StudentPurgeService.kt @@ -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) + } +} diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionStorage.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionStorage.kt index 1fb99da..3a58bc4 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionStorage.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionStorage.kt @@ -78,6 +78,22 @@ class SubmissionStorage( seq: Int, ): Path = rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString()) + /** Удаляет всю папку сдач студента (data/submissions//...). */ + 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) diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationStudentRepository.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationStudentRepository.kt index 8ac633c..7c6fac6 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationStudentRepository.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationStudentRepository.kt @@ -21,4 +21,6 @@ interface GradePublicationStudentRepository : JpaRepository } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminUsersController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminUsersController.kt new file mode 100644 index 0000000..83150e2 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminUsersController.kt @@ -0,0 +1,75 @@ +package ru.ysu.lms.web.admin + +import jakarta.servlet.http.HttpSession +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +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.servlet.mvc.support.RedirectAttributes +import ru.ysu.lms.markdown.MarkdownRenderer +import ru.ysu.lms.web.api.ApiClient +import ru.ysu.lms.web.api.ApiException +import ru.ysu.lms.web.api.deleteStudent +import ru.ysu.lms.web.api.purgeStudent +import ru.ysu.lms.web.api.searchStudents +import ru.ysu.lms.web.session.SessionKeys +import ru.ysu.lms.web.session.SessionUser +import tools.jackson.databind.ObjectMapper + +@Controller +@RequestMapping("/admin/users") +class AdminUsersController( + api: ApiClient, + markdown: MarkdownRenderer, + mapper: ObjectMapper, +) : AdminSupport(api, markdown, mapper) { + @GetMapping + fun list( + @RequestParam(required = false, name = "q") query: String?, + session: HttpSession, + model: Model, + ): String { + val users = runCatching { api.searchStudents(token(session), query) }.getOrDefault(emptyList()) + model.addAttribute("users", users) + model.addAttribute("query", query.orEmpty()) + return "admin/users" + } + + /** Удаляет пользователя и все его данные (сдачи с файлами, оценки, комментарии, защиты, варианты). */ + @PostMapping("/{id}/delete") + fun delete( + @PathVariable id: String, + @RequestParam(required = false, name = "q") query: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String { + val current = session.getAttribute(SessionKeys.USER) as? SessionUser + if (current != null && current.id == id) { + redirect.addFlashAttribute("error", "Нельзя удалить собственный аккаунт") + return redirectTo(query) + } + return try { + val t = token(session) + val student = api.searchStudents(t, null).firstOrNull { it.id == id } + api.purgeStudent(t, id) + val deleted = api.deleteStudent(t, id) + val name = deleted.fullName.ifBlank { student?.fullName ?: deleted.login } + redirect.addFlashAttribute("notice", "Пользователь $name удалён вместе со всеми данными") + redirectTo(query) + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + redirectTo(query) + } + } + + private fun redirectTo(query: String?): String = + if (query.isNullOrBlank()) { + "redirect:/admin/users" + } else { + val encoded = java.net.URLEncoder.encode(query, java.nio.charset.StandardCharsets.UTF_8) + "redirect:/admin/users?q=$encoded" + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt index 06e23ac..83f75cc 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt @@ -134,6 +134,36 @@ fun ApiClient.resetPassword( .body(ResetPasswordDto::class.java) } +/** Поиск пользователей (студентов) для страницы управления. */ +fun ApiClient.searchStudents( + token: String, + query: String?, +): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri { builder -> + builder.path("/api/v1/students").queryParam("q", query.orEmpty()).build() + }.header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + +fun ApiClient.deleteStudent( + token: String, + studentId: String, +): DeleteUserDto = + call { + client + .delete() + .uri("/api/v1/students/{id}", studentId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(DeleteUserDto::class.java) + } + fun ApiClient.credentials( token: String, groupId: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProgress.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProgress.kt index fbf1bb4..6439617 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProgress.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProgress.kt @@ -271,3 +271,16 @@ fun ApiClient.downloadSubmissionAsAdmin( } return downloadOf(entity) } + +fun ApiClient.purgeStudent( + token: String, + studentId: String, +): StudentPurgeResultDto = + call { + client + .delete() + .uri("/api/v1/purge/students/{id}", studentId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(StudentPurgeResultDto::class.java) + } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt index dff0783..8040728 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt @@ -89,6 +89,30 @@ data class StudentCandidateDto( val currentGroupName: String? = null, ) +@JsonIgnoreProperties(ignoreUnknown = true) +data class AdminUserDto( + val id: String = "", + val login: String = "", + val role: String = "STUDENT", + val studentNo: String = "", + val fullName: String = "", + val basis: String = "", + val groupId: String? = null, + val groupName: String? = null, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class DeleteUserDto( + val id: String = "", + val login: String = "", + val fullName: String = "", +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class StudentPurgeResultDto( + val ok: Boolean = false, +) + @JsonIgnoreProperties(ignoreUnknown = true) data class ResetPasswordDto( val password: String = "", diff --git a/web/src/main/resources/templates/admin/users.html b/web/src/main/resources/templates/admin/users.html new file mode 100644 index 0000000..a7bf006 --- /dev/null +++ b/web/src/main/resources/templates/admin/users.html @@ -0,0 +1,47 @@ + + +Пользователи — LMS + +
+

Пользователи

+ +
+ + + Сбросить + Найдено: 0 +
+ +
+ + + + + + + + + + + + + + + + + + + + +
ФИОЛогинНомерГруппаДействия
————без группы +
+ + + +
+
Никого не нашли.
+
+
+ + diff --git a/web/src/main/resources/templates/fragments/base.html b/web/src/main/resources/templates/fragments/base.html index 7a66dbc..3188d61 100644 --- a/web/src/main/resources/templates/fragments/base.html +++ b/web/src/main/resources/templates/fragments/base.html @@ -92,6 +92,7 @@