UI rework: modal dialogs, inline lab editing, manual creation, content management

- Dashboard: real stats (groups, students, labs, average grade), labs/groups
  summary lists, no placeholder tile
- Lab page: view + edit combined — metadata form in modal, condition rendered
  as markdown, per-level variant tables with add/edit/delete dialogs,
  level rename/delete, student level/variant selectors with group filter
- Labs list: create-lab dialog with manual level entry (code; title lines)
- Groups: create-group dialog, add-student dialog (auto password, toast card)
- Content: public /learn reader is the single place; admin buttons (new
  article with chapter/section create-or-select, rename/delete chapters and
  sections, move articles between sections, import zip) appear for admins;
  removed separate 'Контент' admin section
- Buttons use btn classes instead of nav-link
- Favicon: generated SVG book icon
- Security: CSRF synchronizer token interceptor (replaced spring-security in
  web which broke logback under nested jars), session regenerated on login,
  hardened session cookie, gateway actuator limited to health,info
- Jackson 3: nullable-safe request DTOs for labs (FAIL_ON_NULL_FOR_PRIMITIVES)
- academic: POST /labs (manual create with levels), level add/rename/delete,
  content chapters/sections CRUD, article create/move/delete endpoints
This commit is contained in:
2026-09-11 16:00:56 +03:00
parent cca20b5416
commit 08890875e5
45 changed files with 2435 additions and 86 deletions

View File

@@ -29,6 +29,12 @@ class GroupController(private val groupService: GroupService) {
@PostMapping
fun create(@Valid @RequestBody body: CreateGroupRequest): GroupSummary = groupService.create(body)
@PostMapping("/{id}/students")
fun createStudent(
@PathVariable id: UUID,
@Valid @RequestBody body: CreateStudentRequest,
): Credential = groupService.createStudent(id, body)
@PostMapping("/import")
fun import(
@RequestParam("file") file: MultipartFile,

View File

@@ -31,6 +31,44 @@ class GroupService(
@Transactional(readOnly = true)
fun list(): List<GroupSummary> = groups.findAllByOrderByNameAsc().map { summary(it) }
@Transactional
fun createStudent(groupId: UUID, request: CreateStudentRequest): Credential {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val studentNo = request.studentNo.trim()
if (studentNo.isBlank()) throw BadRequestException("Студенческий номер не может быть пустым")
if (students.existsByStudentNo(studentNo)) {
throw ConflictException("Студент с номером $studentNo уже существует")
}
val password = Passwords.generate()
val account = accounts.save(
Account(
login = studentNo,
passwordHash = passwordEncoder.encode(password) ?: "",
role = LmsRole.STUDENT,
mustChangePassword = true,
tempPassword = password,
),
)
val student = students.save(
Student(
studentNo = studentNo,
lastName = request.lastName.trim(),
firstName = request.firstName.trim(),
middleName = request.middleName.trim(),
basis = request.basis.trim(),
note = request.note.trim(),
),
)
student.account = account
enrollments.save(Enrollment(group = group, student = student))
return Credential(
id = account.id.toString(),
studentNo = studentNo,
fullName = student.fullName,
password = password,
)
}
@Transactional
fun create(request: CreateGroupRequest): GroupSummary {
val name = request.name.trim()

View File

@@ -14,6 +14,17 @@ data class CreateGroupRequest(
val name: String,
)
data class CreateStudentRequest(
@field:NotBlank(message = "Укажите студенческий номер")
val studentNo: String,
@field:NotBlank(message = "Укажите фамилию")
val lastName: String,
val firstName: String = "",
val middleName: String = "",
val basis: String = "",
val note: String = "",
)
data class StudentRow(
val id: String,
val studentNo: String,