Admin: users page with search and full cascade delete (identity + progress + files)
This commit is contained in:
@@ -20,7 +20,7 @@ spring:
|
|||||||
- id: progress
|
- id: progress
|
||||||
uri: ${LMS_PROGRESS_URI:http://localhost:8083}
|
uri: ${LMS_PROGRESS_URI:http://localhost:8083}
|
||||||
predicates:
|
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:
|
management:
|
||||||
endpoints:
|
endpoints:
|
||||||
|
|||||||
@@ -262,6 +262,63 @@ class GroupService(
|
|||||||
return GroupDetail(group.id.toString(), group.name, group.taskDurationMin, rows)
|
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
|
@Transactional
|
||||||
fun resetPassword(studentAccountId: UUID): ResetPasswordResponse {
|
fun resetPassword(studentAccountId: UUID): ResetPasswordResponse {
|
||||||
val student =
|
val student =
|
||||||
|
|||||||
@@ -67,6 +67,24 @@ data class StudentCandidate(
|
|||||||
val currentGroupName: String?,
|
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(
|
class ExportFile(
|
||||||
val bytes: ByteArray,
|
val bytes: ByteArray,
|
||||||
val filename: String,
|
val filename: String,
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package ru.ysu.lms.identity.roster
|
package ru.ysu.lms.identity.roster
|
||||||
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
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.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -17,4 +22,17 @@ class StudentController(
|
|||||||
fun resetPassword(
|
fun resetPassword(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
): ResetPasswordResponse = groupService.resetPassword(id)
|
): 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,
|
seq: Int,
|
||||||
): Path = rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString())
|
): 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 {
|
private fun sanitize(name: String): String {
|
||||||
val base = name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "file" }
|
val base = name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "file" }
|
||||||
val clean = base.replace(Regex("[^A-Za-z0-9._\\-\\p{IsCyrillic}]"), "_").take(120)
|
val clean = base.replace(Regex("[^A-Za-z0-9._\\-\\p{IsCyrillic}]"), "_").take(120)
|
||||||
|
|||||||
@@ -21,4 +21,6 @@ interface GradePublicationStudentRepository : JpaRepository<GradePublicationStud
|
|||||||
labId: UUID,
|
labId: UUID,
|
||||||
studentId: UUID,
|
studentId: UUID,
|
||||||
): Boolean
|
): Boolean
|
||||||
|
|
||||||
|
fun findByStudentId(studentId: UUID): List<GradePublicationStudent>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,6 +134,36 @@ fun ApiClient.resetPassword(
|
|||||||
.body(ResetPasswordDto::class.java)
|
.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(
|
fun ApiClient.credentials(
|
||||||
token: String,
|
token: String,
|
||||||
groupId: String,
|
groupId: String,
|
||||||
|
|||||||
@@ -271,3 +271,16 @@ fun ApiClient.downloadSubmissionAsAdmin(
|
|||||||
}
|
}
|
||||||
return downloadOf(entity)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,6 +89,30 @@ data class StudentCandidateDto(
|
|||||||
val currentGroupName: String? = null,
|
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)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class ResetPasswordDto(
|
data class ResetPasswordDto(
|
||||||
val password: String = "",
|
val password: String = "",
|
||||||
|
|||||||
47
web/src/main/resources/templates/admin/users.html
Normal file
47
web/src/main/resources/templates/admin/users.html
Normal 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>
|
||||||
@@ -92,6 +92,7 @@
|
|||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<th:block th:if="${currentUser != null}">
|
<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/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: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:unless="${currentUser.admin}" th:href="@{/student/labs}" class="nav-link">Лабораторные</a>
|
||||||
<a th:href="@{/change-password}" class="nav-link">Смена пароля</a>
|
<a th:href="@{/change-password}" class="nav-link">Смена пароля</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user