Admin: users page with search and full cascade delete (identity + progress + files)

This commit is contained in:
2026-10-02 11:53:01 +03:00
parent b94538cb7e
commit c0cf11a5a3
14 changed files with 370 additions and 1 deletions

View File

@@ -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"
}
}

View File

@@ -134,6 +134,36 @@ fun ApiClient.resetPassword(
.body(ResetPasswordDto::class.java)
}
/** Поиск пользователей (студентов) для страницы управления. */
fun ApiClient.searchStudents(
token: String,
query: String?,
): List<AdminUserDto> {
val type = object : ParameterizedTypeReference<List<AdminUserDto>>() {}
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,

View File

@@ -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)
}

View File

@@ -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 = "",

View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title>Пользователи — LMS</title></head>
<body>
<div id="content">
<h1 class="page-title">Пользователи</h1>
<form class="flex flex-wrap items-center gap-2 mb-4" method="get" th:action="@{/admin/users}">
<input class="input input-sm" name="q" th:value="${query}" placeholder="Поиск по ФИО, логину, номеру или группе…" aria-label="Поиск пользователей">
<button class="btn btn-secondary btn-sm" type="submit">Найти</button>
<a class="btn btn-secondary btn-sm" th:href="@{/admin/users}" th:if="${!#strings.isEmpty(query)}">Сбросить</a>
<span class="text-sm text-muted">Найдено: <span th:text="${#lists.size(users)}">0</span></span>
</form>
<div class="card card-flush">
<table id="users-table" class="tablekit">
<thead>
<tr>
<th class="th-sortable is-asc" title="ФИО — по возрастанию">ФИО</th>
<th>Логин</th>
<th>Номер</th>
<th>Группа</th>
<th class="text-right" data-nosort>Действия</th>
</tr>
</thead>
<tbody>
<tr th:each="u : ${users}">
<td class="font-medium" th:text="${u.fullName}">—</td>
<td class="font-mono" th:text="${u.login}">—</td>
<td class="font-mono" th:text="${u.studentNo}">—</td>
<td><a th:if="${u.groupId}" th:href="@{/admin/groups/{id}(id=${u.groupId})}" th:text="${u.groupName}">—</a><span th:unless="${u.groupId}" class="text-muted">без группы</span></td>
<td class="text-right">
<form class="inline-form" th:action="@{/admin/users/{id}/delete(id=${u.id})}" method="post"
onsubmit="return confirm('Удалить пользователя безвозвратно? Будут удалены аккаунт, группа, оценки с комментариями, сдачи с файлами и задания защит.');">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="q" th:value="${query}">
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(users)}"><td colspan="5" class="empty">Никого не нашли.</td></tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -92,6 +92,7 @@
<nav class="nav">
<th:block th:if="${currentUser != null}">
<a th:if="${currentUser.admin}" th:href="@{/admin/groups}" class="nav-link">Группы</a>
<a th:if="${currentUser.admin}" th:href="@{/admin/users}" class="nav-link">Пользователи</a>
<a th:if="${currentUser.admin}" th:href="@{/admin/labs}" class="nav-link">Лабораторные</a>
<a th:unless="${currentUser.admin}" th:href="@{/student/labs}" class="nav-link">Лабораторные</a>
<a th:href="@{/change-password}" class="nav-link">Смена пароля</a>