feat(identity): группы-когорты, история и импорт v2

This commit is contained in:
2026-10-26 15:47:49 +03:00
parent 24a92632fd
commit 07a223b759
34 changed files with 1459 additions and 248 deletions

View File

@@ -98,11 +98,13 @@ cp .env.prod.example .env.prod # заполнить LMS_DB_PASSWORD
администратора (`/setup`); далее — вход по логину и паролю.
- **Доступ**: JWT (RSA, ключи identity генерирует при первом старте и хранит в БД),
проверка токена на gateway, роли ADMIN/STUDENT, CSRF на всех POST-формах,
проверка токена на gateway, роли ADMIN/TEACHER/STUDENT, CSRF на всех POST-формах,
session fixation-защита; вход студента с обязательной сменой пароля.
- **Группы и студенты**: создание групп, импорт `.xls/.xlsx/.csv`, ручное
добавление студента (генерация пароля) и «Добавить существующего» (перевод из
другой группы), «Отчислить из группы», выгрузка логинов XLSX/CSV, сброс пароля.
- **Группы и студенты**: группы-когорты (поток, курс, номер, степень, год набора),
импорт `.xls/.xlsx/.csv` по шаблону `поток/курс/номер/степень/год набора/ФИО/№/база`,
ручное добавление студента (генерация пароля) и «Добавить существующего»
(перевод из другой группы), «Отчислить из группы», выгрузка логинов XLSX/CSV,
сброс пароля, история членства.
- **Лабораторные**: импорт ZIP (`lab.yaml` + `README.md` + `variants/*.md` +
`assets/`); создание через UI с произвольным числом уровней; единая форма
настроек с одной кнопкой «Сохранить всё»; статусы draft/published

View File

@@ -11,16 +11,27 @@
## Шаг 2. Группы и студенты
**Группы → Импорт** — загрузите `.xls`/`.xlsx`/`.csv` со списком группы
(столбцы: «Студенческий номер», «Фамилия Имя Отчество», «Основа обучения»).
Название группы берётся из файла или указывается при импорте. Логином студента
становится студенческий номер, пароль генерируется (временный, с обязательной
сменой при первом входе).
**Группы → Импорт** — загрузите `.xls`/`.xlsx`/`.csv` со списком группы.
Обязательные столбцы:
```text
поток | курс | номер | степень | год набора | ФИО | № | база
```
Все строки файла должны относиться к одной группе. Группа создаётся по полям
(имя собирается автоматически, например `ПИ-11БО`). Логином студента становится
номер (`№`), пароль генерируется (временный, с обязательной сменой при первом
входе). Ключ слияния — `student_no`: повторный импорт обновляет студента, а не
дублирует.
Импорт атомарный: при ошибке в любой строке данные не меняются, а в интерфейсе
показывается отчёт с номерами строк.
Дальше по каждому студенту при необходимости:
* **«Сбросить пароль»** — выдать новый временный пароль;
* **«Добавить существующего»** — перевести студента из другой группы;
* **«Отчислить из группы»** — убрать из группы (аккаунт и данные сохраняются).
* **«Отчислить из группы»** — убрать из группы, студент получает статус
«отчислен»; аккаунт, сдачи и оценки сохраняются.
Выгрузить логины: **«Логины XLSX» / «CSV»**.

View File

@@ -12,6 +12,8 @@ classDiagram
+String login
+String passwordHash
+LmsRole role
+AccountStatus status
+Instant archivedAt
+Boolean mustChangePassword
+String tempPassword
+Instant createdAt
@@ -25,11 +27,19 @@ classDiagram
+String middleName
+String basis
+String note
+StudentStatus status
+Short enrollmentYear
+String fullName
}
class GroupEntity {
+UUID id
+String name
+String stream
+Short course
+Short number
+String degree
+Short enrollmentYear
+GroupStatus status
+Int taskDurationMin
+Instant createdAt
}
@@ -37,6 +47,17 @@ classDiagram
+UUID id
+Instant enrolledAt
}
class MembershipHistory {
+UUID id
+UUID studentId
+UUID fromGroupId
+UUID toGroupId
+MembershipAction action
+String reason
+UUID actorAccountId
+UUID promotionRunId
+Instant at
}
class Setting {
+String key
+String value
@@ -44,12 +65,15 @@ classDiagram
class LmsRole {
<<enumeration>>
ADMIN
TEACHER
STUDENT
}
Account "1" --> "1" Student : @MapsId account_id
GroupEntity "1" --> "*" Enrollment : group_id
Student "1" --> "*" Enrollment : student_id
Student "1" --> "*" MembershipHistory : student_id
GroupEntity "1" --> "*" MembershipHistory : from/to_group_id
Account --> LmsRole : role
```
@@ -68,20 +92,28 @@ classDiagram
class GroupService {
+list() List~GroupSummary~
+create(CreateGroupRequest) GroupSummary
+createStudent(UUID, CreateStudentRequest) Credential
+createStudent(UUID, CreateStudentRequest, UUID) Credential
+candidates(UUID) List~StudentCandidate~
+enroll(UUID, UUID) GroupDetail
+unenroll(UUID, UUID) GroupDetail
+import(String, String, ByteArray) ImportResult
+enroll(UUID, UUID, UUID) GroupDetail
+unenroll(UUID, UUID, UUID) GroupDetail
+groupOfStudent(UUID) GroupSummary
+detail(UUID) GroupDetail
+resetPassword(UUID) ResetPasswordResponse
+credentials(UUID, String) ExportFile
}
class GroupImportService {
+import(String, ByteArray, UUID) ImportResult
}
class GroupCohortResolver {
+resolve(CreateGroupRequest) GroupCohort
+validate(GroupCohort) GroupCohort
+defaultEnrollmentYear(Short) Short
}
class AccountRepository
class GroupRepository
class EnrollmentRepository
class StudentRepository
class MembershipHistoryRepository
class SettingRepository
class StudentImportParser {
+parse(String, ByteArray) ParsedGroup
@@ -96,6 +128,7 @@ classDiagram
AuthController --> AuthService
GroupController --> GroupService
GroupController --> GroupImportService
StudentController --> GroupService
AuthService --> AccountRepository
AuthService --> StudentRepository
@@ -103,7 +136,14 @@ classDiagram
GroupService --> EnrollmentRepository
GroupService --> StudentRepository
GroupService --> AccountRepository
GroupService --> StudentImportParser
GroupService --> MembershipHistoryRepository
GroupService --> GroupCohortResolver
GroupImportService --> GroupRepository
GroupImportService --> EnrollmentRepository
GroupImportService --> StudentRepository
GroupImportService --> AccountRepository
GroupImportService --> MembershipHistoryRepository
GroupImportService --> StudentImportParser
GroupService --> CredentialsExporter
GroupService --> Passwords
```
@@ -516,6 +556,8 @@ erDiagram
varchar login UK
varchar password_hash
varchar role
varchar status
timestamptz archived_at
boolean must_change_password
varchar temp_password
timestamptz created_at
@@ -529,10 +571,18 @@ erDiagram
varchar middle_name
varchar basis
text note
varchar status
smallint enrollment_year
}
GROUPS {
uuid id PK
varchar name UK
varchar name
varchar stream
smallint course
smallint number
varchar degree
smallint enrollment_year
varchar status
int task_duration_min
timestamptz created_at
}
@@ -542,14 +592,27 @@ erDiagram
uuid student_id FK
timestamptz enrolled_at
}
MEMBERSHIP_HISTORY {
uuid id PK
uuid student_id FK
uuid from_group_id FK
uuid to_group_id FK
varchar action
text reason
uuid actor_account_id
uuid promotion_run_id
timestamptz at
}
SETTINGS {
varchar key PK
varchar value
}
ACCOUNTS ||--|| STUDENTS : "account_id"
ACCOUNTS ||--o| STUDENTS : "account_id"
GROUPS ||--o{ ENROLLMENTS : "group_id"
STUDENTS ||--o{ ENROLLMENTS : "student_id"
STUDENTS ||--o{ MEMBERSHIP_HISTORY : "student_id"
GROUPS ||--o{ MEMBERSHIP_HISTORY : "from/to_group_id"
SEMESTERS {
uuid id PK

95
docs/11-stage1-report.md Normal file
View File

@@ -0,0 +1,95 @@
# 11. Отчёт этапа 1: identity
**Дата:** 2026-10-23.
**Статус:** этап 1 выполнен; этап 2 (academic) — следующий.
## 1. Что сделано
### Роли и аккаунты
- Добавлена роль `TEACHER` в `LmsRole`; БД уже допускает её миграцией `V4`.
- `Account` получил `status` (`ACTIVE/ARCHIVED`) и `archivedAt`.
- Вход запрещён для архивных аккаунтов и студентов в статусах
`EXPELLED/GRADUATED/ARCHIVED`.
### Группы-когорты
- `GroupEntity`: `stream`, `course`, `number`, `degree`, `enrollmentYear`,
`status` (`ACTIVE/GRADUATED/ARCHIVED`).
- `name` больше не уникально; уникальность — по кортежу когорты.
- `GroupNameParser` разбирает `ПИ-11БО`, `ИВТ-ТОП-12БО` и т.п.
- `GroupCohortResolver` валидирует степень, курс, номер и год набора,
вычисляет год набора по умолчанию.
- Создание группы через админку — по полям, имя собирается автоматически.
### Студенты и история
- `Student`: `status` и `enrollmentYear`.
- `MembershipHistory`: `ENROLL`, `TRANSFER`, `IMPORT`, `EXPELL` и остальные
действия; записи создаются при зачислении, переводе, импорте и отчислении.
- При отчислении студент получает `EXPELLED`, аккаунт и данные сохраняются.
- Повторное зачисление возвращает `ACTIVE` и пишет `TRANSFER`.
### Импорт Excel/CSV v2
Шаблон:
```text
поток | курс | номер | степень | год набора | ФИО | № | база
```
- Поддерживаются `.xls`, `.xlsx`, `.csv`, разделители `;`, `,`, tab.
- ФИО можно одной колонкой или отдельными `Фамилия/Имя/Отчество`.
- Все строки должны быть одной группой.
- Проверяются степень, диапазон курса, год набора, пустые поля,
дубли `№` внутри файла.
- Импорт атомарный: при ошибках данные не меняются, возвращается отчёт
с номерами строк.
- Ключ слияния — `student_no`; группа ищется по кортежу, при отсутствии
создаётся.
- Новые студенты получают временный пароль; пароли показываются один раз.
### UI
- Страница групп: год набора, статус, создание по полям, описание нового импорта.
- Страница группы: год набора, статус группы, статус студента.
- Страница пользователей: статус студента.
- При ошибке импорта показывается таблица «строка — ошибка».
## 2. Тесты
Добавлены:
- `GroupNameParserTest` — разбор имени, диапазоны курсов;
- `StudentImportParserTest` — валидный шаблон, дубли, разные группы,
недопустимый курс, неизвестная шапка;
- `GroupServiceIntegrationTest` — создание когорты, дубликат, импорт,
атомарность ошибок, перевод, отчисление, запрет входа после отчисления.
Проверки:
- `./gradlew :services:identity-service:test :web:test` — успешно;
- `ktlintCheck` и `detekt` для identity/web — успешно.
## 3. Ограничения этапа
- Импорт поддерживает одну группу на файл; мультигрупповой файл — позже.
- Ведущие нули номера группы нормализуются к числу (`01` → `1`);
имя строится из числового номера. Если нужны разные `ПИ-11БО` и `ПИ-101БО`
как отдельные когорты, потребуется хранить номер строкой.
- Роль `TEACHER` уже есть в домене, но кабинет преподавателя и назначения
на курс — этапы 2–3.
- `ACADEMIC_LEAVE` пока может входить; режим «только чтение» — этап 3.
- Отчисление пока простое; причины, подтверждение и аудит — этап 4.
- Старый формат импорта (только студенческие колонки) больше не поддерживается.
## 4. Следующий этап
Этап 2 — academic:
- курсы, `course_teachers`, `course_groups`;
- `course_no` и `/learn/course/{course_no}/...`;
- `labs.course_id` и перенос лаб в курс C;
- `chapters.course_id/audience`, перенос материалов;
- активы и копирование лабораторных;
- админ-UI курсов.

View File

@@ -2,6 +2,7 @@ package ru.ysu.lms.security
enum class LmsRole {
ADMIN,
TEACHER,
STUDENT,
;

View File

@@ -13,6 +13,8 @@ import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.AccountStatus
import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.AccountRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.JwtProperties
@@ -61,10 +63,22 @@ class AuthService(
backoff.recordFailure(login)
throw UnauthorizedException("Неверный логин или пароль")
}
ensureLoginAllowed(account)
backoff.reset(login)
return toResponse(account, displayName(account))
}
private fun ensureLoginAllowed(account: Account) {
if (account.status != AccountStatus.ACTIVE) {
throw UnauthorizedException("Аккаунт архивирован")
}
if (account.role != LmsRole.STUDENT) return
val status = students.findById(account.id).map { it.status }.orElse(null)
if (status in setOf(StudentStatus.EXPELLED, StudentStatus.GRADUATED, StudentStatus.ARCHIVED)) {
throw UnauthorizedException("Доступ к системе закрыт")
}
}
@Transactional(readOnly = true)
fun me(accountId: UUID): MeResponse {
val account =

View File

@@ -27,6 +27,11 @@ class Account(
var mustChangePassword: Boolean = true,
@Column(name = "temp_password", length = 64)
var tempPassword: String? = null,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
var status: AccountStatus = AccountStatus.ACTIVE,
@Column(name = "archived_at")
var archivedAt: Instant? = null,
@Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(),
@Column(name = "updated_at", nullable = false)

View File

@@ -0,0 +1,6 @@
package ru.ysu.lms.identity.domain
enum class AccountStatus {
ACTIVE,
ARCHIVED,
}

View File

@@ -2,6 +2,8 @@ package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
@@ -13,8 +15,19 @@ import java.util.UUID
class GroupEntity(
@Id
var id: UUID = Ids.v7(),
@Column(nullable = false, unique = true, length = 100)
@Column(nullable = false, length = 100)
var name: String = "",
@Column(length = 50)
var stream: String? = null,
var course: Short? = null,
var number: Short? = null,
@Column(length = 2)
var degree: String? = null,
@Column(name = "enrollment_year")
var enrollmentYear: Short? = null,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
var status: GroupStatus = GroupStatus.ACTIVE,
@Column(name = "task_duration_min", nullable = false)
var taskDurationMin: Int = 30,
@Column(name = "created_at", nullable = false)

View File

@@ -0,0 +1,7 @@
package ru.ysu.lms.identity.domain
enum class GroupStatus {
ACTIVE,
GRADUATED,
ARCHIVED,
}

View File

@@ -0,0 +1,14 @@
package ru.ysu.lms.identity.domain
enum class MembershipAction {
ENROLL,
TRANSFER,
ACADEMIC,
RETURN,
GRADUATE,
EXPELL,
PROMOTE,
IMPORT,
ARCHIVE,
RESTORE,
}

View File

@@ -0,0 +1,35 @@
package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "membership_history")
class MembershipHistory(
@Id
var id: UUID = Ids.v7(),
@Column(name = "student_id", nullable = false)
var studentId: UUID = UUID(0, 0),
@Column(name = "from_group_id")
var fromGroupId: UUID? = null,
@Column(name = "to_group_id")
var toGroupId: UUID? = null,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
var action: MembershipAction = MembershipAction.ENROLL,
@Column(nullable = false)
var reason: String = "",
@Column(name = "actor_account_id")
var actorAccountId: UUID? = null,
@Column(name = "promotion_run_id")
var promotionRunId: UUID? = null,
@Column(nullable = false)
var at: Instant = Instant.now(),
)

View File

@@ -2,6 +2,8 @@ package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
@@ -28,6 +30,11 @@ class Student(
var basis: String = "",
@Column(name = "note", nullable = false)
var note: String = "",
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
var status: StudentStatus = StudentStatus.ACTIVE,
@Column(name = "enrollment_year")
var enrollmentYear: Short? = null,
@OneToOne(fetch = FetchType.LAZY)
@MapsId
@JoinColumn(name = "account_id")

View File

@@ -0,0 +1,9 @@
package ru.ysu.lms.identity.domain
enum class StudentStatus {
ACTIVE,
ACADEMIC_LEAVE,
EXPELLED,
GRADUATED,
ARCHIVED,
}

View File

@@ -9,5 +9,15 @@ interface GroupRepository : JpaRepository<GroupEntity, UUID> {
fun existsByName(name: String): Boolean
fun findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
stream: String,
degree: String,
number: Short,
course: Short,
enrollmentYear: Short,
): GroupEntity?
fun findAllByOrderByNameAsc(): List<GroupEntity>
fun findAllByOrderByEnrollmentYearDescNameAsc(): List<GroupEntity>
}

View File

@@ -0,0 +1,9 @@
package ru.ysu.lms.identity.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.identity.domain.MembershipHistory
import java.util.UUID
interface MembershipHistoryRepository : JpaRepository<MembershipHistory, UUID> {
fun findByStudentIdOrderByAtDesc(studentId: UUID): List<MembershipHistory>
}

View File

@@ -0,0 +1,40 @@
package ru.ysu.lms.identity.roster
/** Разобранная группа-когорта. Имя всегда производное от полей. */
data class GroupCohort(
val stream: String,
val course: Short,
val number: Short,
val degree: String,
val enrollmentYear: Short,
) {
val name: String
get() = "$stream-$course$number$degree"
}
object GroupNameParser {
private val pattern = Regex("^(.+)-(\\d)(\\d+)(БО|МО)$")
fun parse(name: String): GroupCohort? {
val match = pattern.matchEntire(name.trim()) ?: return null
val course = match.groupValues[2].toShortOrNull() ?: return null
val number = match.groupValues[3].toShortOrNull() ?: return null
return GroupCohort(
stream = match.groupValues[1],
course = course,
number = number,
degree = match.groupValues[4],
enrollmentYear = 0, // год набора из имени не выводится
)
}
fun validCourse(
degree: String,
course: Short,
): Boolean =
when (degree) {
"БО" -> course in 1..4
"МО" -> course in 1..2
else -> false
}
}

View File

@@ -0,0 +1,76 @@
package ru.ysu.lms.identity.roster
import org.springframework.stereotype.Component
import ru.ysu.lms.common.BadRequestException
import java.time.LocalDate
@Component
class GroupCohortResolver {
fun resolve(request: CreateGroupRequest): GroupCohort {
val fromName =
request.name
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { GroupNameParser.parse(it) }
val stream =
request.stream
?.trim()
?.takeIf { it.isNotBlank() }
?: fromName?.stream
val course = request.course ?: fromName?.course
val number = request.number ?: fromName?.number
val degree =
request.degree
?.trim()
?.uppercase()
?.takeIf { it.isNotBlank() }
?: fromName?.degree
val missing = listOf(stream, course, number, degree).any { it == null }
if (missing) {
throw BadRequestException("Укажите поток, курс, номер, степень и год набора")
}
val resolvedStream = stream!!
val resolvedCourse = course!!
val resolvedNumber = number!!
val resolvedDegree = degree!!
val year = request.enrollmentYear ?: defaultEnrollmentYear(resolvedCourse)
return validate(GroupCohort(resolvedStream, resolvedCourse, resolvedNumber, resolvedDegree, year))
}
fun validate(cohort: GroupCohort): GroupCohort {
validateStream(cohort.stream)
validateDegreeAndCourse(cohort)
validateNumberAndYear(cohort)
return cohort
}
private fun validateStream(stream: String) {
if (stream.isBlank() || stream.length > 50) {
throw BadRequestException("Поток: от 1 до 50 символов")
}
}
private fun validateDegreeAndCourse(cohort: GroupCohort) {
if (cohort.degree !in setOf("БО", "МО")) {
throw BadRequestException("Степень должна быть БО или МО")
}
if (!GroupNameParser.validCourse(cohort.degree, cohort.course)) {
throw BadRequestException("Курс ${cohort.course} недопустим для степени ${cohort.degree}")
}
}
private fun validateNumberAndYear(cohort: GroupCohort) {
if (cohort.number <= 0) {
throw BadRequestException("Номер группы должен быть положительным")
}
if (cohort.enrollmentYear !in 2000..2100) {
throw BadRequestException("Год набора вне диапазона 2000–2100")
}
}
fun defaultEnrollmentYear(course: Short): Short {
val now = LocalDate.now()
val academicYearStart = if (now.monthValue >= 9) now.year else now.year - 1
return (academicYearStart - (course - 1)).toShort()
}
}

View File

@@ -26,6 +26,7 @@ import java.util.UUID
@PreAuthorize("hasRole('ADMIN')")
class GroupController(
private val groupService: GroupService,
private val importService: GroupImportService,
) {
@GetMapping
fun list(): List<GroupSummary> = groupService.list()
@@ -39,17 +40,18 @@ class GroupController(
fun createStudent(
@PathVariable id: UUID,
@Valid @RequestBody body: CreateStudentRequest,
): Credential = groupService.createStudent(id, body)
@AuthenticationPrincipal jwt: Jwt,
): Credential = groupService.createStudent(id, body, UUID.fromString(jwt.subject))
@PostMapping("/import")
fun import(
@RequestParam("file") file: MultipartFile,
@RequestParam(name = "name", required = false) name: String?,
@AuthenticationPrincipal jwt: Jwt,
): ImportResult {
if (file.isEmpty) {
throw BadRequestException("Выберите файл со списком группы")
}
return groupService.import(name, file.originalFilename ?: "group.xlsx", file.bytes)
return importService.import(file.originalFilename ?: "group.xlsx", file.bytes, UUID.fromString(jwt.subject))
}
/** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */
@@ -63,14 +65,16 @@ class GroupController(
fun enroll(
@PathVariable id: UUID,
@PathVariable studentId: UUID,
): GroupDetail = groupService.enroll(id, studentId)
@AuthenticationPrincipal jwt: Jwt,
): GroupDetail = groupService.enroll(id, studentId, UUID.fromString(jwt.subject))
/** Отчислить студента из группы. */
@DeleteMapping("/{id}/students/{studentId}")
fun unenroll(
@PathVariable id: UUID,
@PathVariable studentId: UUID,
): GroupDetail = groupService.unenroll(id, studentId)
@AuthenticationPrincipal jwt: Jwt,
): GroupDetail = groupService.unenroll(id, studentId, UUID.fromString(jwt.subject))
@GetMapping("/me")
@PreAuthorize("isAuthenticated()")

View File

@@ -0,0 +1,183 @@
package ru.ysu.lms.identity.roster
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.identity.auth.Passwords
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.Enrollment
import ru.ysu.lms.identity.domain.GroupEntity
import ru.ysu.lms.identity.domain.GroupStatus
import ru.ysu.lms.identity.domain.MembershipAction
import ru.ysu.lms.identity.domain.MembershipHistory
import ru.ysu.lms.identity.domain.Student
import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.AccountRepository
import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.MembershipHistoryRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.LmsRole
import java.util.UUID
@Suppress("LongParameterList")
@Service
class GroupImportService(
private val groups: GroupRepository,
private val enrollments: EnrollmentRepository,
private val students: StudentRepository,
private val accounts: AccountRepository,
private val history: MembershipHistoryRepository,
private val passwordEncoder: PasswordEncoder,
private val parser: StudentImportParser,
) {
@Transactional
fun import(
filename: String,
bytes: ByteArray,
actorAccountId: UUID?,
): ImportResult {
val parsed = parser.parse(filename, bytes)
if (parsed.errors.isNotEmpty()) {
return ImportResult(
applied = false,
groupName = parsed.cohort?.name,
errors = parsed.errors.map { ImportError(it.row, it.message) },
)
}
val cohort = parsed.cohort
if (cohort == null) {
return ImportResult(
applied = false,
errors = listOf(ImportError(0, "Не удалось определить группу: проверьте поля потока, курса, номера и степени")),
)
}
val group = findOrCreateGroup(cohort)
val added = mutableListOf<Credential>()
val updated = mutableListOf<String>()
parsed.students.forEach { parsedStudent ->
val student = upsertStudent(parsedStudent, cohort, added, updated)
moveToGroup(student, group, actorAccountId)
}
return ImportResult(
applied = true,
groupId = group.id.toString(),
groupName = group.name,
added = added,
updated = updated,
)
}
private fun findOrCreateGroup(cohort: GroupCohort): GroupEntity {
val existing =
groups.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
cohort.stream,
cohort.degree,
cohort.number,
cohort.course,
cohort.enrollmentYear,
)
if (existing != null) {
if (existing.status != GroupStatus.ACTIVE) {
throw ConflictException("Группа ${existing.name} не активна")
}
return existing
}
return groups.save(
GroupEntity(
name = cohort.name,
stream = cohort.stream,
course = cohort.course,
number = cohort.number,
degree = cohort.degree,
enrollmentYear = cohort.enrollmentYear,
status = GroupStatus.ACTIVE,
),
)
}
private fun upsertStudent(
parsed: ParsedStudent,
cohort: GroupCohort,
added: MutableList<Credential>,
updated: MutableList<String>,
): Student {
val existing = students.findByStudentNo(parsed.studentNo)
if (existing != null) {
existing.lastName = parsed.lastName
existing.firstName = parsed.firstName
existing.middleName = parsed.middleName
existing.basis = parsed.basis
existing.note = parsed.note
existing.status = StudentStatus.ACTIVE
existing.enrollmentYear = cohort.enrollmentYear
students.save(existing)
updated += parsed.studentNo
return existing
}
if (accounts.existsByLogin(parsed.studentNo)) {
throw ConflictException("Логин ${parsed.studentNo} уже занят другим аккаунтом")
}
val password = Passwords.generate()
val account =
accounts.save(
Account(
login = parsed.studentNo,
passwordHash = passwordEncoder.encode(password) ?: "",
role = LmsRole.STUDENT,
mustChangePassword = true,
tempPassword = password,
),
)
val student =
Student(
studentNo = parsed.studentNo,
lastName = parsed.lastName,
firstName = parsed.firstName,
middleName = parsed.middleName,
basis = parsed.basis,
note = parsed.note,
status = StudentStatus.ACTIVE,
enrollmentYear = cohort.enrollmentYear,
account = account,
)
students.save(student)
added +=
Credential(
id = account.id.toString(),
studentNo = parsed.studentNo,
fullName = student.fullName,
password = password,
)
return student
}
private fun moveToGroup(
student: Student,
group: GroupEntity,
actorAccountId: UUID?,
) {
val accountId = student.accountId ?: return
val enrollment = enrollments.findByStudentAccountId(accountId)
val fromGroupId = enrollment?.group?.id
if (fromGroupId == group.id) return
if (enrollment == null) {
enrollments.save(Enrollment(group = group, student = student))
} else {
enrollment.group = group
enrollments.save(enrollment)
}
history.save(
MembershipHistory(
studentId = accountId,
fromGroupId = fromGroupId,
toGroupId = group.id,
action = MembershipAction.IMPORT,
reason = "Импорт Excel",
actorAccountId = actorAccountId,
),
)
}
}

View File

@@ -10,37 +10,43 @@ import ru.ysu.lms.identity.auth.Passwords
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.Enrollment
import ru.ysu.lms.identity.domain.GroupEntity
import ru.ysu.lms.identity.domain.GroupStatus
import ru.ysu.lms.identity.domain.MembershipAction
import ru.ysu.lms.identity.domain.MembershipHistory
import ru.ysu.lms.identity.domain.Student
import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.AccountRepository
import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.MembershipHistoryRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.LmsRole
import java.util.UUID
@Suppress("LongParameterList")
@Service
class GroupService(
private val groups: GroupRepository,
private val enrollments: EnrollmentRepository,
private val students: StudentRepository,
private val accounts: AccountRepository,
private val history: MembershipHistoryRepository,
private val passwordEncoder: PasswordEncoder,
private val parser: StudentImportParser,
private val cohortResolver: GroupCohortResolver,
) {
@Transactional(readOnly = true)
fun list(): List<GroupSummary> = groups.findAllByOrderByNameAsc().map { summary(it) }
fun list(): List<GroupSummary> = groups.findAllByOrderByEnrollmentYearDescNameAsc().map { summary(it) }
@Transactional
fun createStudent(
groupId: UUID,
request: CreateStudentRequest,
actorAccountId: UUID? = null,
): Credential {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val group = activeGroup(groupId)
val studentNo = request.studentNo.trim()
if (studentNo.isBlank()) throw BadRequestException("Студенческий номер не может быть пустым")
if (students.existsByStudentNo(studentNo)) {
throw ConflictException("Студент с номером $studentNo уже существует")
}
ensureStudentNumberAvailable(studentNo)
val password = Passwords.generate()
val account =
accounts.save(
@@ -52,7 +58,6 @@ class GroupService(
tempPassword = password,
),
)
// Student.id — это account_id через @MapsId: ассоциацию задаём до persist.
val student =
students.save(
Student(
@@ -62,10 +67,19 @@ class GroupService(
middleName = request.middleName.trim(),
basis = request.basis.trim(),
note = request.note.trim(),
status = StudentStatus.ACTIVE,
enrollmentYear = group.enrollmentYear,
account = account,
),
)
enrollments.save(Enrollment(group = group, student = student))
recordHistory(
studentNo = student.accountId!!,
fromGroupId = null,
toGroupId = group.id,
action = MembershipAction.ENROLL,
actorAccountId = actorAccountId,
)
return Credential(
id = account.id.toString(),
studentNo = studentNo,
@@ -76,11 +90,31 @@ class GroupService(
@Transactional
fun create(request: CreateGroupRequest): GroupSummary {
val name = request.name.trim()
if (groups.existsByName(name)) {
throw ConflictException("Группа «$name» уже существует")
val cohort = cohortResolver.resolve(request)
val existing =
groups.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
cohort.stream,
cohort.degree,
cohort.number,
cohort.course,
cohort.enrollmentYear,
)
if (existing != null) {
throw ConflictException("Группа «${cohort.name}» с таким годом набора уже существует")
}
return summary(groups.save(GroupEntity(name = name)))
val group =
groups.save(
GroupEntity(
name = cohort.name,
stream = cohort.stream,
course = cohort.course,
number = cohort.number,
degree = cohort.degree,
enrollmentYear = cohort.enrollmentYear,
status = GroupStatus.ACTIVE,
),
)
return summary(group)
}
/** Студенты, которых ещё нет в этой группе (включая состоящих в других). */
@@ -116,129 +150,52 @@ class GroupService(
fun enroll(
groupId: UUID,
studentId: UUID,
actorAccountId: UUID? = null,
): GroupDetail {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val group = activeGroup(groupId)
val student = students.findById(studentId).orElseThrow { NotFoundException("Студент не найден") }
val existing = enrollments.findByStudentAccountId(studentId)
if (existing != null) {
if (existing.group?.id == group.id) {
throw ConflictException("Студент уже в этой группе")
}
// unique-констрейт на student_id: переводим перепривязкой, без delete+insert
val fromGroupId = existing?.group?.id
if (fromGroupId == group.id) {
throw ConflictException("Студент уже в этой группе")
}
if (existing == null) {
enrollments.save(Enrollment(group = group, student = student))
} else {
existing.group = group
enrollments.save(existing)
} else {
enrollments.save(Enrollment(group = group, student = student))
}
student.status = StudentStatus.ACTIVE
student.enrollmentYear = group.enrollmentYear
students.save(student)
recordHistory(studentId, fromGroupId, group.id, MembershipAction.TRANSFER, actorAccountId)
return detail(groupId)
}
/** Отчислить из группы (студент и его аккаунт сохраняются). */
/** Убрать студента из группы; аккаунт и данные сохраняются. */
@Transactional
fun unenroll(
groupId: UUID,
studentId: UUID,
actorAccountId: UUID? = null,
): GroupDetail {
val enrollment =
enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
?: throw NotFoundException("Студент не входит в эту группу")
enrollments.delete(enrollment)
val student = students.findById(studentId).orElse(null)
if (student != null) {
student.status = StudentStatus.EXPELLED
students.save(student)
}
recordHistory(studentId, groupId, null, MembershipAction.EXPELL, actorAccountId)
return detail(groupId)
}
@Transactional
fun import(
requestedName: String?,
filename: String,
bytes: ByteArray,
): ImportResult {
val parsed = parser.parse(filename, bytes)
val groupName = requestedName?.trim().takeUnless { it.isNullOrBlank() } ?: parsed.groupName
val group = groups.findByName(groupName) ?: groups.save(GroupEntity(name = groupName))
val added = mutableListOf<Credential>()
val updated = mutableListOf<String>()
parsed.students.forEach { parsedStudent ->
val student = upsertStudent(parsedStudent, added, updated)
enrollIntoGroup(group, student)
}
return ImportResult(group.id.toString(), group.name, added, updated)
}
/** Обновляет существующего студента или создаёт нового с временным паролем. */
private fun upsertStudent(
parsed: ParsedStudent,
added: MutableList<Credential>,
updated: MutableList<String>,
): Student {
val existing = students.findByStudentNo(parsed.studentNo)
if (existing != null) {
existing.lastName = parsed.lastName
existing.firstName = parsed.firstName
existing.middleName = parsed.middleName
existing.basis = parsed.basis
existing.note = parsed.note
students.save(existing)
updated.add(parsed.studentNo)
return existing
}
val password = Passwords.generate()
val account =
accounts.save(
Account(
login = parsed.studentNo,
passwordHash = passwordEncoder.encode(password) ?: "",
role = LmsRole.STUDENT,
mustChangePassword = true,
tempPassword = password,
),
)
val created =
Student(
studentNo = parsed.studentNo,
lastName = parsed.lastName,
firstName = parsed.firstName,
middleName = parsed.middleName,
basis = parsed.basis,
note = parsed.note,
)
created.account = account
students.save(created)
added.add(
Credential(
id = account.id.toString(),
studentNo = parsed.studentNo,
fullName = parsed.fullName,
password = password,
),
)
return created
}
/** Зачисляет студента в группу (или переводит из другой). */
private fun enrollIntoGroup(
group: GroupEntity,
student: Student,
) {
val accountId = student.accountId!!
val enrollment = enrollments.findByStudentAccountId(accountId)
if (enrollment == null) {
enrollments.save(Enrollment(group = group, student = student))
} else {
enrollment.group = group
enrollments.save(enrollment)
}
}
@Transactional(readOnly = true)
fun groupOfStudent(accountId: UUID): GroupSummary? {
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null
return GroupSummary(
id = group.id.toString(),
name = group.name,
studentCount = enrollments.findAllByGroupId(group.id).size.toLong(),
taskDurationMin = group.taskDurationMin,
)
return summary(group)
}
@Transactional(readOnly = true)
@@ -255,11 +212,24 @@ class GroupService(
studentNo = student.studentNo,
fullName = student.fullName,
basis = student.basis,
status = student.status.name,
enrollmentYear = student.enrollmentYear,
mustChangePassword = account.mustChangePassword,
tempPassword = account.tempPassword,
)
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
return GroupDetail(group.id.toString(), group.name, group.taskDurationMin, rows)
return GroupDetail(
id = group.id.toString(),
name = group.name,
stream = group.stream,
course = group.course,
number = group.number,
degree = group.degree,
enrollmentYear = group.enrollmentYear,
status = group.status.name,
taskDurationMin = group.taskDurationMin,
students = rows,
)
}
/** Список всех пользователей (студентов) с фильтром по подстроке. */
@@ -286,6 +256,8 @@ class GroupService(
studentNo = s.studentNo,
fullName = s.fullName,
basis = s.basis,
status = s.status.name,
enrollmentYear = s.enrollmentYear,
groupId = groupInfo?.first?.toString(),
groupName = groupInfo?.second,
)
@@ -341,27 +313,69 @@ class GroupService(
): ExportFile {
val detail = detail(groupId)
val safeName = detail.name.replace(Regex("[^\\p{L}\\p{N}._-]+"), "_")
val year = detail.enrollmentYear?.let { " ($it)" }.orEmpty()
return when (format.lowercase()) {
"csv" ->
ExportFile(
bytes = CredentialsExporter.csv(detail.students),
filename = "credentials_$safeName.csv",
filename = "credentials_$safeName$year.csv",
contentType = "text/csv; charset=utf-8",
)
"xlsx" ->
ExportFile(
bytes = CredentialsExporter.xlsx(detail.students),
filename = "credentials_$safeName.xlsx",
filename = "credentials_$safeName$year.xlsx",
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
else -> throw BadRequestException("Формат должен быть xlsx или csv")
}
}
private fun ensureStudentNumberAvailable(studentNo: String) {
if (students.existsByStudentNo(studentNo)) {
throw ConflictException("Студент с номером $studentNo уже существует")
}
if (accounts.existsByLogin(studentNo)) {
throw ConflictException("Логин $studentNo уже занят другим аккаунтом")
}
}
private fun activeGroup(groupId: UUID): GroupEntity {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
if (group.status != GroupStatus.ACTIVE) {
throw BadRequestException("Группа ${group.name} не активна")
}
return group
}
private fun recordHistory(
studentNo: UUID,
fromGroupId: UUID?,
toGroupId: UUID?,
action: MembershipAction,
actorAccountId: UUID?,
) {
history.save(
MembershipHistory(
studentId = studentNo,
fromGroupId = fromGroupId,
toGroupId = toGroupId,
action = action,
actorAccountId = actorAccountId,
),
)
}
private fun summary(group: GroupEntity): GroupSummary =
GroupSummary(
id = group.id.toString(),
name = group.name,
stream = group.stream,
course = group.course,
number = group.number,
degree = group.degree,
enrollmentYear = group.enrollmentYear,
status = group.status.name,
studentCount = enrollments.countByGroupId(group.id),
taskDurationMin = group.taskDurationMin,
)

View File

@@ -5,13 +5,23 @@ import jakarta.validation.constraints.NotBlank
data class GroupSummary(
val id: String,
val name: String,
val stream: String? = null,
val course: Short? = null,
val number: Short? = null,
val degree: String? = null,
val enrollmentYear: Short? = null,
val status: String = "ACTIVE",
val studentCount: Long,
val taskDurationMin: Int,
)
data class CreateGroupRequest(
@field:NotBlank(message = "Укажите название группы")
val name: String,
val name: String? = null,
val stream: String? = null,
val course: Short? = null,
val number: Short? = null,
val degree: String? = null,
val enrollmentYear: Short? = null,
)
data class CreateStudentRequest(
@@ -30,6 +40,8 @@ data class StudentRow(
val studentNo: String,
val fullName: String,
val basis: String,
val status: String = "ACTIVE",
val enrollmentYear: Short? = null,
val mustChangePassword: Boolean,
val tempPassword: String?,
)
@@ -37,6 +49,12 @@ data class StudentRow(
data class GroupDetail(
val id: String,
val name: String,
val stream: String? = null,
val course: Short? = null,
val number: Short? = null,
val degree: String? = null,
val enrollmentYear: Short? = null,
val status: String = "ACTIVE",
val taskDurationMin: Int,
val students: List<StudentRow>,
)
@@ -49,10 +67,17 @@ data class Credential(
)
data class ImportResult(
val groupId: String,
val groupName: String,
val added: List<Credential>,
val updated: List<String>,
val applied: Boolean = true,
val groupId: String? = null,
val groupName: String? = null,
val added: List<Credential> = emptyList(),
val updated: List<String> = emptyList(),
val errors: List<ImportError> = emptyList(),
)
data class ImportError(
val row: Int,
val message: String,
)
data class ResetPasswordResponse(
@@ -75,6 +100,8 @@ data class AdminUser(
val studentNo: String,
val fullName: String,
val basis: String,
val status: String = "ACTIVE",
val enrollmentYear: Short? = null,
val groupId: String?,
val groupName: String?,
)

View File

@@ -18,11 +18,45 @@ data class ParsedStudent(
get() = listOf(lastName, firstName, middleName).filter { it.isNotBlank() }.joinToString(" ")
}
data class ParsedGroup(
val groupName: String,
val students: List<ParsedStudent>,
data class ImportRowError(
val row: Int,
val message: String,
)
data class ParsedGroup(
val cohort: GroupCohort?,
val students: List<ParsedStudent>,
val errors: List<ImportRowError>,
)
private data class HeaderRule(
val key: String,
val exact: Set<String> = emptySet(),
val prefixes: List<String> = emptyList(),
) {
fun matches(value: String): Boolean = value in exact || prefixes.any { value.startsWith(it) }
}
private val HEADER_RULES =
listOf(
HeaderRule("stream", exact = setOf("поток"), prefixes = listOf("поток ")),
HeaderRule("course", exact = setOf("курс", "курс обучения")),
HeaderRule("groupNumber", exact = setOf("номер", "номер группы")),
HeaderRule("degree", prefixes = listOf("степень")),
HeaderRule("year", exact = setOf("год"), prefixes = listOf("год набора")),
HeaderRule(
"studentNo",
exact = setOf("№"),
prefixes = listOf("№ ", "студенч", "зачёт", "зачет", "номер студента"),
),
HeaderRule("fullName", prefixes = listOf("фио", "фамилия имя")),
HeaderRule("lastName", prefixes = listOf("фамили")),
HeaderRule("firstName", exact = setOf("имя"), prefixes = listOf("имя ")),
HeaderRule("middleName", prefixes = listOf("отчеств")),
HeaderRule("basis", prefixes = listOf("база", "основ")),
HeaderRule("note", prefixes = listOf("примеч")),
)
@Component
class StudentImportParser {
fun parse(
@@ -30,11 +64,18 @@ class StudentImportParser {
bytes: ByteArray,
): ParsedGroup {
val lower = filename.lowercase()
return when {
lower.endsWith(".csv") -> parseRows(filename, parseCsv(bytes))
lower.endsWith(".xls") || lower.endsWith(".xlsx") -> parseRows(filename, parseExcel(bytes))
else -> throw BadRequestException("Поддерживаются файлы .xls, .xlsx и .csv")
val rows =
when {
lower.endsWith(".csv") -> parseCsv(bytes)
lower.endsWith(".xls") || lower.endsWith(".xlsx") -> parseExcel(bytes)
else -> throw BadRequestException("Поддерживаются файлы .xls, .xlsx и .csv")
}
val headerIndex = rows.indexOfFirst { row -> mapColumns(row).isNotEmpty() }
if (headerIndex < 0) {
throw BadRequestException("Не найдена шапка таблицы. Нужны столбцы: поток, курс, номер, степень, год набора, ФИО, №.")
}
val columns = requireColumns(rows[headerIndex])
return parseRows(rows, headerIndex, columns)
}
private fun parseExcel(bytes: ByteArray): List<List<String>> {
@@ -55,9 +96,7 @@ class StudentImportParser {
private fun parseCsv(bytes: ByteArray): List<List<String>> {
val text = bytes.toString(Charsets.UTF_8).removePrefix("\uFEFF")
val lines = text.split(Regex("\r?\n")).filter { it.isNotBlank() }
if (lines.isEmpty()) {
throw BadRequestException("Файл пуст")
}
if (lines.isEmpty()) throw BadRequestException("Файл пуст")
val delimiter = detectDelimiter(lines.first())
return lines.map { splitCsvLine(it, delimiter) }
}
@@ -89,101 +128,228 @@ class StudentImportParser {
}
private fun parseRows(
filename: String,
rows: List<List<String>>,
headerIndex: Int,
columns: Map<String, Int>,
): ParsedGroup {
val headerIndex = requireHeader(rows)
val columns = requireColumns(rows[headerIndex])
val students = rows.drop(headerIndex + 1).mapNotNull { parseStudent(it, columns) }
if (students.isEmpty()) {
val errors = mutableListOf<ImportRowError>()
val students = mutableListOf<ParsedStudent>()
val seenStudentNos = mutableMapOf<String, Int>()
var cohort: GroupCohort? = null
rows.drop(headerIndex + 1).forEachIndexed { index, row ->
if (row.all { it.isBlank() }) return@forEachIndexed
val rowNumber = headerIndex + index + 2
val rowCohort = parseCohort(row, columns, rowNumber, errors)
if (rowCohort != null) {
if (cohort == null) {
cohort = rowCohort
} else if (cohort != rowCohort) {
errors += ImportRowError(rowNumber, "Все строки файла должны относиться к одной группе")
}
}
val student = parseStudent(row, columns, rowNumber, errors, seenStudentNos)
if (student != null) students += student
}
if (students.isEmpty() && errors.isEmpty()) {
throw BadRequestException("В таблице не найдено ни одного студента")
}
return ParsedGroup(groupNameFrom(filename, rows), students)
return ParsedGroup(cohort = cohort, students = students, errors = errors)
}
private fun requireHeader(rows: List<List<String>>): Int {
val headerIndex = rows.indexOfFirst { row -> row.any { isStudentHeader(it) } }
if (headerIndex < 0) {
throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.")
}
return headerIndex
private fun parseCohort(
row: List<String>,
columns: Map<String, Int>,
rowNumber: Int,
errors: MutableList<ImportRowError>,
): GroupCohort? {
val fields =
CohortFields(
stream = cell(row, columns, "stream"),
course = cell(row, columns, "course"),
number = cell(row, columns, "groupNumber"),
degree = cell(row, columns, "degree").uppercase(),
year = cell(row, columns, "year"),
)
val before = errors.size
val numbers =
CohortNumbers(
course = parseInt(fields.course),
number = parseInt(fields.number),
year = parseInt(fields.year),
)
validateRequiredCohortFields(fields, rowNumber, errors)
validateCohortNumbers(fields, numbers, rowNumber, errors)
validateCohortDegree(fields.degree, numbers.course, rowNumber, errors)
if (errors.size > before) return null
return GroupCohort(
stream = fields.stream,
course = numbers.course!!.toShort(),
number = numbers.number!!.toShort(),
degree = fields.degree,
enrollmentYear = numbers.year!!.toShort(),
)
}
private fun requireColumns(header: List<String>): Map<String, Int> {
val columns = mapColumns(header)
if (columns["studentNo"] == null) {
throw BadRequestException("Не найден столбец «Студенческий номер»")
}
return columns
private fun validateRequiredCohortFields(
fields: CohortFields,
rowNumber: Int,
errors: MutableList<ImportRowError>,
) {
if (fields.stream.isBlank()) errors += ImportRowError(rowNumber, "Не заполнен поток")
if (fields.course.isBlank()) errors += ImportRowError(rowNumber, "Не заполнен курс")
if (fields.number.isBlank()) errors += ImportRowError(rowNumber, "Не заполнен номер группы")
if (fields.degree.isBlank()) errors += ImportRowError(rowNumber, "Не заполнена степень")
if (fields.year.isBlank()) errors += ImportRowError(rowNumber, "Не заполнен год набора")
}
private fun validateCohortNumbers(
fields: CohortFields,
numbers: CohortNumbers,
rowNumber: Int,
errors: MutableList<ImportRowError>,
) {
if (fields.course.isNotBlank() && numbers.course == null) {
errors += ImportRowError(rowNumber, "Курс должен быть числом")
}
if (fields.number.isNotBlank() && numbers.number == null) {
errors += ImportRowError(rowNumber, "Номер группы должен быть числом")
}
if (fields.year.isNotBlank() && numbers.year == null) {
errors += ImportRowError(rowNumber, "Год набора должен быть числом")
}
if (numbers.number != null && numbers.number <= 0) {
errors += ImportRowError(rowNumber, "Номер группы должен быть положительным")
}
if (numbers.number != null && numbers.number > Short.MAX_VALUE) {
errors += ImportRowError(rowNumber, "Слишком большой номер группы")
}
if (numbers.year != null && numbers.year !in 2000..2100) {
errors += ImportRowError(rowNumber, "Год набора вне диапазона 2000–2100")
}
}
private data class CohortNumbers(
val course: Int?,
val number: Int?,
val year: Int?,
)
private fun validateCohortDegree(
degree: String,
course: Int?,
rowNumber: Int,
errors: MutableList<ImportRowError>,
) {
if (degree.isNotBlank() && degree !in setOf("БО", "МО")) {
errors += ImportRowError(rowNumber, "Степень должна быть БО или МО")
}
if (course != null && degree in setOf("БО", "МО") && !GroupNameParser.validCourse(degree, course.toShort())) {
errors += ImportRowError(rowNumber, "Курс $course недопустим для степени $degree")
}
}
private data class CohortFields(
val stream: String,
val course: String,
val number: String,
val degree: String,
val year: String,
)
private fun parseStudent(
row: List<String>,
columns: Map<String, Int>,
rowNumber: Int,
errors: MutableList<ImportRowError>,
seenStudentNos: MutableMap<String, Int>,
): ParsedStudent? {
fun cell(key: String): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
val studentNo = normalizeNumber(cell("studentNo"))
val lastName = cell("lastName")
if (studentNo.isBlank() && lastName.isBlank()) return null
val before = errors.size
val studentNo = normalizeNumber(cell(row, columns, "studentNo"))
if (studentNo.isBlank()) {
throw BadRequestException("В одной из строк отсутствует студенческий номер")
errors += ImportRowError(rowNumber, "Не заполнен № студента")
} else {
val previous = seenStudentNos.put(studentNo, rowNumber)
if (previous != null) {
errors += ImportRowError(rowNumber, "№ $studentNo уже встречается в строке $previous")
}
}
val fullName = cell(row, columns, "fullName")
val lastName: String
val firstName: String
val middleName: String
if (fullName.isNotBlank()) {
val parts = fullName.split(Regex("\\s+")).filter { it.isNotBlank() }
lastName = parts.getOrElse(0) { "" }
firstName = parts.getOrElse(1) { "" }
middleName = parts.drop(2).joinToString(" ")
} else {
lastName = cell(row, columns, "lastName")
firstName = cell(row, columns, "firstName")
middleName = cell(row, columns, "middleName")
}
if (lastName.isBlank()) errors += ImportRowError(rowNumber, "Не заполнено ФИО")
if (errors.size > before) return null
return ParsedStudent(
studentNo = studentNo,
lastName = lastName,
firstName = cell("firstName"),
middleName = cell("middleName"),
basis = cell("basis"),
note = cell("note"),
firstName = firstName,
middleName = middleName,
basis = cell(row, columns, "basis"),
note = cell(row, columns, "note"),
)
}
private fun isStudentHeader(value: String): Boolean {
val normalized = value.lowercase().replace("\u00a0", " ").trim()
return normalized.startsWith("студен") ||
normalized.startsWith("студ.") ||
normalized.startsWith("зачёт") ||
normalized.startsWith("зачет")
private fun requireColumns(header: List<String>): Map<String, Int> {
val columns = mapColumns(header)
val missing = mutableListOf<String>()
val names =
mapOf(
"stream" to "поток",
"course" to "курс",
"groupNumber" to "номер",
"degree" to "степень",
"year" to "год набора",
"studentNo" to "№",
)
names.forEach { (key, title) ->
if (columns[key] == null) missing += title
}
if (columns["fullName"] == null && columns["lastName"] == null) missing += "ФИО"
if (missing.isNotEmpty()) {
throw BadRequestException("Не найдены столбцы: ${missing.joinToString(", ")}. Проверьте шапку таблицы.")
}
return columns
}
private fun mapColumns(header: List<String>): Map<String, Int> {
val result = mutableMapOf<String, Int>()
header.forEachIndexed { index, raw ->
val value = raw.lowercase().replace("\u00a0", " ").trim()
fun putIfAbsent(
key: String,
vararg prefixes: String,
) {
if (key !in result && prefixes.any { value.startsWith(it) }) {
result[key] = index
}
}
putIfAbsent("studentNo", "студенч", "студ.", "студбил", "зачёт", "зачет")
putIfAbsent("lastName", "фамили")
putIfAbsent("firstName", "имя")
putIfAbsent("middleName", "отчеств")
putIfAbsent("basis", "основ")
putIfAbsent("note", "примеч")
val value = normalizeHeader(raw)
HEADER_RULES
.firstOrNull { it.matches(value) }
?.let { result.putIfAbsent(it.key, index) }
}
return result
}
private fun normalizeNumber(value: String): String = if (value.endsWith(".0")) value.dropLast(2) else value
private fun normalizeHeader(value: String): String =
value
.lowercase()
.replace('\u00a0', ' ')
.trim()
.replace(Regex("\\s+"), " ")
private fun groupNameFrom(
filename: String,
rows: List<List<String>>,
): String {
val groupRegex = Regex("[А-ЯA-Z][А-ЯA-Za-z]*[- ]?\\d{1,4}[А-ЯA-Za-z]*")
for (row in rows.take(12)) {
for (cell in row) {
val value = cell.trim()
if (value.length in 2..30 && groupRegex.matches(value)) {
return value
}
}
}
return filename.substringBeforeLast('.').trim().ifBlank { "Группа" }
}
private fun cell(
row: List<String>,
columns: Map<String, Int>,
key: String,
): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
private fun parseInt(value: String): Int? = normalizeNumber(value).takeIf { it.isNotBlank() }?.toIntOrNull()
private fun normalizeNumber(value: String): String = if (value.endsWith(".0")) value.dropLast(2) else value
}

View File

@@ -0,0 +1,48 @@
package ru.ysu.lms.identity.roster
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class GroupNameParserTest {
@Test
fun `parses stream course number and degree`() {
val group = GroupNameParser.parse("ПИ-11БО")
assertEquals("ПИ", group?.stream)
assertEquals(1, group?.course)
assertEquals(1, group?.number)
assertEquals("БО", group?.degree)
}
@Test
fun `parses stream with hyphen`() {
val group = GroupNameParser.parse("ИВТ-ТОП-12БО")
assertEquals("ИВТ-ТОП", group?.stream)
assertEquals(1, group?.course)
assertEquals(2, group?.number)
}
@Test
fun `keeps leading zero in number`() {
val group = GroupNameParser.parse("ПИ-101БО")
assertEquals(1, group?.course)
assertEquals(1, group?.number)
}
@Test
fun `rejects invalid names`() {
assertNull(GroupNameParser.parse("ПИ-11"))
assertNull(GroupNameParser.parse("ПИ-11XX"))
assertNull(GroupNameParser.parse(""))
}
@Test
fun `validates course ranges`() {
assertTrue(GroupNameParser.validCourse("БО", 4))
assertFalse(GroupNameParser.validCourse("БО", 5))
assertTrue(GroupNameParser.validCourse("МО", 2))
assertFalse(GroupNameParser.validCourse("МО", 3))
}
}

View File

@@ -0,0 +1,171 @@
package ru.ysu.lms.identity.roster
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.springframework.transaction.annotation.Transactional
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.AuthService
import ru.ysu.lms.identity.auth.LoginRequest
import ru.ysu.lms.identity.domain.MembershipAction
import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.MembershipHistoryRepository
import ru.ysu.lms.identity.repo.StudentRepository
import java.util.UUID
@SpringBootTest
@Testcontainers
@Transactional
class GroupServiceIntegrationTest {
companion object {
@Container
val pg =
PostgreSQLContainer("postgres:18").apply {
withDatabaseName("lms")
withUsername("lms")
withPassword("lms")
}
@JvmStatic
@DynamicPropertySource
fun datasource(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url") { pg.jdbcUrl }
registry.add("spring.datasource.username") { "lms" }
registry.add("spring.datasource.password") { "lms" }
}
}
@Autowired lateinit var groupService: GroupService
@Autowired lateinit var importService: GroupImportService
@Autowired lateinit var authService: AuthService
@Autowired lateinit var groups: GroupRepository
@Autowired lateinit var students: StudentRepository
@Autowired lateinit var enrollments: EnrollmentRepository
@Autowired lateinit var history: MembershipHistoryRepository
@Test
fun `creates cohort group and rejects duplicate`() {
val request =
CreateGroupRequest(
stream = "ПИ",
course = 1,
number = 1,
degree = "БО",
enrollmentYear = 2025,
)
val created = groupService.create(request)
assertEquals("ПИ-11БО", created.name)
assertEquals(2025, created.enrollmentYear)
assertEquals("ACTIVE", created.status)
val error =
runCatching { groupService.create(request) }.exceptionOrNull()
assertTrue(error is ConflictException)
}
@Test
fun `imports students and records membership history`() {
val result = importService.import("group.csv", csv(validRows()), null)
assertTrue(result.applied)
assertEquals("ПИ-21БО", result.groupName)
assertEquals(2, result.added.size)
val group = groups.findByName("ПИ-21БО")!!
val groupId = group.id
assertEquals(2, enrollments.countByGroupId(groupId))
val student = students.findByStudentNo("2001")!!
val studentId = student.accountId!!
assertEquals(StudentStatus.ACTIVE, student.status)
assertEquals(2025.toShort(), student.enrollmentYear)
val events = history.findByStudentIdOrderByAtDesc(studentId)
assertEquals(MembershipAction.IMPORT, events.first().action)
}
@Test
fun `does not apply import when there are row errors`() {
val groupsBefore = groups.count()
val studentsBefore = students.count()
val bad =
"""
ПИ;1;1;БО;2025;Иванов Иван;3001;бюджет
ПИ;1;1;БО;2025;Петров Пётр;3001;бюджет
""".trimIndent()
val result = importService.import("bad.csv", csv(bad), null)
assertFalse(result.applied)
assertTrue(result.errors.isNotEmpty())
assertEquals(groupsBefore, groups.count())
assertEquals(studentsBefore, students.count())
}
@Test
fun `transfer and expulsion keep history`() {
val first = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 1, degree = "БО", enrollmentYear = 2025))
val second = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 2, degree = "БО", enrollmentYear = 2025))
val imported = importService.import("group.csv", csv(validRows()), null)
val student = students.findByStudentNo("2001")!!
val studentId = student.accountId!!
groupService.enroll(UUID.fromString(second.id), studentId, null)
assertEquals(
second.id,
enrollments
.findByStudentAccountId(studentId)
?.group
?.id
.toString(),
)
assertEquals(MembershipAction.TRANSFER, history.findByStudentIdOrderByAtDesc(studentId).first().action)
groupService.unenroll(UUID.fromString(second.id), studentId, null)
assertEquals(StudentStatus.EXPELLED, students.findById(studentId).orElseThrow().status)
assertEquals(MembershipAction.EXPELL, history.findByStudentIdOrderByAtDesc(studentId).first().action)
assertEquals(1, groups.findAll().count { it.id == UUID.fromString(first.id) })
assertTrue(imported.applied)
}
@Test
fun `expelled student cannot login`() {
val group = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 9, degree = "БО", enrollmentYear = 2025))
val credential =
groupService.createStudent(
UUID.fromString(group.id),
CreateStudentRequest(studentNo = "9001", lastName = "Иванов", firstName = "Иван"),
null,
)
authService.login(LoginRequest("9001", credential.password))
groupService.unenroll(UUID.fromString(group.id), UUID.fromString(credential.id), null)
val error = runCatching { authService.login(LoginRequest("9001", credential.password)) }.exceptionOrNull()
assertTrue(error is UnauthorizedException)
}
private fun validRows(): String =
"""
ПИ;2;1;БО;2025;Иванов Иван;2001;бюджет
ПИ;2;1;БО;2025;Петров Пётр;2002;бюджет
""".trimIndent()
private fun csv(rows: String): ByteArray = ("поток;курс;номер;степень;год набора;ФИО;№;база\n" + rows).toByteArray()
}

View File

@@ -0,0 +1,79 @@
package ru.ysu.lms.identity.roster
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import ru.ysu.lms.common.BadRequestException
class StudentImportParserTest {
private val parser = StudentImportParser()
@Test
fun `parses new template and splits full name`() {
val result = parser.parse("group.csv", csv(validRows()))
assertTrue(result.errors.isEmpty())
assertEquals("ПИ-11БО", result.cohort?.name)
assertEquals(2, result.students.size)
assertEquals("Иванов", result.students[0].lastName)
assertEquals("Иван", result.students[0].firstName)
assertEquals("Иванович", result.students[0].middleName)
assertEquals("1001", result.students[0].studentNo)
}
@Test
fun `reports duplicate student number`() {
val rows =
"""
ПИ;1;1;БО;2025;Иванов Иван;1001;бюджет
ПИ;1;1;БО;2025;Петров Пётр;1001;бюджет
""".trimIndent()
val result = parser.parse("group.csv", csv(rows))
assertFalse(result.errors.isEmpty())
assertTrue(result.errors.any { it.message.contains("уже встречается") })
}
@Test
fun `reports different groups in one file`() {
val rows =
"""
ПИ;1;1;БО;2025;Иванов Иван;1001;бюджет
ПИ;2;1;БО;2025;Петров Пётр;1002;бюджет
""".trimIndent()
val result = parser.parse("group.csv", csv(rows))
assertFalse(result.errors.isEmpty())
assertTrue(result.errors.any { it.message.contains("одной группе") }, result.errors.toString())
}
@Test
fun `reports invalid course for degree`() {
val rows = "ПИ;5;1;БО;2025;Иванов Иван;1001;бюджет"
val result = parser.parse("group.csv", csv(rows))
assertFalse(result.errors.isEmpty())
assertTrue(result.errors.any { it.message.contains("недопустим") })
}
@Test
fun `rejects unknown header`() {
val bytes = "ФИО;№\nИванов Иван;1001".toByteArray()
val error =
runCatching { parser.parse("group.csv", bytes) }
.exceptionOrNull()
assertTrue(error is BadRequestException)
}
private fun validRows(): String =
"""
ПИ;1;1;БО;2025;Иванов Иван Иванович;1001;бюджет
ПИ;1;1;БО;2025;Петров Пётр;1002;бюджет
""".trimIndent()
private val header = "поток;курс;номер;степень;год набора;ФИО;№;база"
private fun csv(rows: String): ByteArray = (header + "\n" + rows).toByteArray()
}

View File

@@ -3,6 +3,15 @@ package ru.ysu.lms.web.admin
// Формы админ-контроллеров: связываются через @ModelAttribute.
// Имена полей совпадают с именами полей в шаблонах (то же, что было в @RequestParam).
data class CreateGroupForm(
val name: String? = null,
val stream: String? = null,
val course: Short? = null,
val number: Short? = null,
val degree: String? = null,
val enrollmentYear: Short? = null,
)
data class CreateStudentForm(
val studentNo: String = "",
val lastName: String = "",

View File

@@ -19,6 +19,7 @@ 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.CreateGroupPayload
import ru.ysu.lms.web.api.DefenseDto
import ru.ysu.lms.web.api.GradeSetPayload
import ru.ysu.lms.web.api.GroupDetailDto
@@ -100,12 +101,22 @@ class AdminGroupController(
@PostMapping("/groups")
fun createGroup(
@RequestParam name: String,
@ModelAttribute form: CreateGroupForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.createGroup(token(session), name.trim())
api.createGroup(
token(session),
CreateGroupPayload(
name = form.name?.trim()?.takeIf { it.isNotBlank() },
stream = form.stream?.trim()?.takeIf { it.isNotBlank() },
course = form.course,
number = form.number,
degree = form.degree?.trim()?.takeIf { it.isNotBlank() },
enrollmentYear = form.enrollmentYear,
),
)
redirect.addFlashAttribute("notice", "Группа создана")
"redirect:/admin/groups"
} catch (ex: ApiException) {
@@ -116,21 +127,19 @@ class AdminGroupController(
@PostMapping("/groups/import")
fun importGroup(
@RequestParam("file") file: MultipartFile,
@RequestParam(name = "name", required = false) name: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
if (file.isEmpty) throw ApiException(400, "Выберите файл со списком группы")
val result =
api.importGroup(
token(session),
name,
file.originalFilename ?: "group.xlsx",
file.bytes,
)
val result = api.importGroup(token(session), file.originalFilename ?: "group.xlsx", file.bytes)
redirect.addFlashAttribute("importResult", result)
"redirect:/admin/groups/${result.groupId}"
if (!result.applied) {
redirect.addFlashAttribute("error", "Импорт не применён: исправьте ошибки в файле")
"redirect:/admin/groups"
} else {
"redirect:/admin/groups/${result.groupId}"
}
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups"

View File

@@ -129,7 +129,7 @@ class AdminStudentController(
): String =
try {
api.unenrollStudent(token(session), groupId, studentId)
redirect.addFlashAttribute("notice", "Студент отчислён из группы (аккаунт и данные сохранены)")
redirect.addFlashAttribute("notice", "Студент отчислен: аккаунт и данные сохранены, вход закрыт")
"redirect:/admin/groups/$groupId"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)

View File

@@ -22,7 +22,7 @@ fun ApiClient.listGroups(token: String): List<GroupDto> {
fun ApiClient.createGroup(
token: String,
name: String,
payload: CreateGroupPayload,
): GroupDto =
call {
client
@@ -30,14 +30,13 @@ fun ApiClient.createGroup(
.uri("/api/v1/groups")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(CreateGroupPayload(name))
.body(payload)
.retrieve()
.body(GroupDto::class.java)
}
fun ApiClient.importGroup(
token: String,
name: String?,
filename: String,
bytes: ByteArray,
): ImportResultDto {
@@ -48,9 +47,6 @@ fun ApiClient.importGroup(
override fun getFilename(): String = filename
},
)
if (!name.isNullOrBlank()) {
parts.add("name", name)
}
return call {
client
.post()

View File

@@ -39,12 +39,23 @@ data class ChangePasswordPayload(
data class GroupDto(
val id: String = "",
val name: String = "",
val stream: String? = null,
val course: Short? = null,
val number: Short? = null,
val degree: String? = null,
val enrollmentYear: Short? = null,
val status: String = "ACTIVE",
val studentCount: Long = 0,
val taskDurationMin: Int = 30,
)
data class CreateGroupPayload(
val name: String,
val name: String? = null,
val stream: String? = null,
val course: Short? = null,
val number: Short? = null,
val degree: String? = null,
val enrollmentYear: Short? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -57,10 +68,18 @@ data class CredentialDto(
@JsonIgnoreProperties(ignoreUnknown = true)
data class ImportResultDto(
val groupId: String = "",
val groupName: String = "",
val applied: Boolean = true,
val groupId: String? = null,
val groupName: String? = null,
val added: List<CredentialDto> = emptyList(),
val updated: List<String> = emptyList(),
val errors: List<ImportErrorDto> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ImportErrorDto(
val row: Int = 0,
val message: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -69,6 +88,8 @@ data class StudentRowDto(
val studentNo: String = "",
val fullName: String = "",
val basis: String = "",
val status: String = "ACTIVE",
val enrollmentYear: Short? = null,
val mustChangePassword: Boolean = false,
val tempPassword: String? = null,
)
@@ -77,6 +98,12 @@ data class StudentRowDto(
data class GroupDetailDto(
val id: String = "",
val name: String = "",
val stream: String? = null,
val course: Short? = null,
val number: Short? = null,
val degree: String? = null,
val enrollmentYear: Short? = null,
val status: String = "ACTIVE",
val taskDurationMin: Int = 30,
val students: List<StudentRowDto> = emptyList(),
)
@@ -97,6 +124,8 @@ data class AdminUserDto(
val studentNo: String = "",
val fullName: String = "",
val basis: String = "",
val status: String = "ACTIVE",
val enrollmentYear: Short? = null,
val groupId: String? = null,
val groupName: String? = null,
)

View File

@@ -14,6 +14,11 @@
th:text="${(p % 10 == 1 and p % 100 != 11) ? 'лабораторная' : ((p % 10 >= 2 and p % 10 <= 4 and (p % 100 < 12 or p % 100 > 14)) ? 'лабораторные' : 'лабораторных')}">лабораторных</span>
с открытыми оценками
</p>
<p class="meta">
<span th:if="${group.enrollmentYear != null}">Набор <b th:text="${group.enrollmentYear}">2025</b> · </span>
<span class="badge" th:classappend="${group.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
th:text="${group.status == 'ACTIVE' ? 'активна' : (group.status == 'GRADUATED' ? 'выпущена' : 'архив')}">—</span>
</p>
</div>
<div class="actions">
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">
@@ -118,6 +123,8 @@
aria-label="Статус пароля"></span>
<div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div>
</div>
<div th:if="${row.status != 'ACTIVE'}" class="badge badge-warn"
th:text="${row.status == 'EXPELLED' ? 'отчислен' : (row.status == 'ACADEMIC_LEAVE' ? 'академ' : (row.status == 'GRADUATED' ? 'выпущен' : 'архив'))}">—</div>
</td>
<td th:each="cell, iterStat : ${row.cells}" class="text-center cell-lab"
th:attr="data-lab=${cell.labId},data-sort=${cell.variantNumber},data-filter-label=${cell.variantNumber != null ? 'вариант ' + cell.variantNumber : 'без варианта'}">
@@ -184,7 +191,7 @@
title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить</button>
</form>
<form th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}"
method="post" data-confirm="Отчислить этого студента из группы? Аккаунт, сдача и оценки сохранятся; студент сможет войти и будет без группы.">
method="post" data-confirm="Отчислить этого студента из группы? Аккаунт, сдача и оценки сохранятся, но вход в систему будет закрыт.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-sm btn-danger" type="submit">Отчислить</button>
</form>

View File

@@ -13,16 +13,45 @@
</div>
</div>
<div th:if="${importResult != null and !importResult.applied}" class="card card-body mb-6">
<h2 class="section-title">Импорт не применён</h2>
<p class="text-sm text-muted">Исправьте ошибки в файле и загрузите его снова. Данные не изменены.</p>
<div class="scroll-x">
<table class="tablekit">
<thead><tr><th>Строка</th><th>Ошибка</th></tr></thead>
<tbody>
<tr th:each="e : ${importResult.errors}">
<td th:text="${e.row}">—</td>
<td th:text="${e.message}">—</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card card-flush">
<table id="groups-table" class="tablekit">
<thead><tr><th class="th-sortable is-asc" title="Название — по возрастанию">Название</th><th>Студентов</th><th data-nosort>Пароли</th></tr></thead>
<thead>
<tr>
<th class="th-sortable is-asc" title="Название — по возрастанию">Название</th>
<th>Год набора</th>
<th>Статус</th>
<th>Студентов</th>
<th data-nosort>Пароли</th>
</tr>
</thead>
<tbody>
<tr th:each="group : ${groups}">
<td><a th:href="@{/admin/groups/{id}(id=${group.id})}" th:text="${group.name}">—</a></td>
<td th:text="${group.enrollmentYear} ?: '—'">—</td>
<td>
<span class="badge" th:classappend="${group.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
th:text="${group.status == 'ACTIVE' ? 'активна' : (group.status == 'GRADUATED' ? 'выпущена' : 'архив')}">—</span>
</td>
<td th:text="${group.studentCount}">0</td>
<td><a th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">XLSX</a><span class="nav-sep ml-2">·</span><a class="ml-2" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='csv')}">CSV</a></td>
</tr>
<tr th:if="${#lists.isEmpty(groups)}"><td colspan="3" class="empty">Групп пока нет.</td></tr>
<tr th:if="${#lists.isEmpty(groups)}"><td colspan="5" class="empty">Групп пока нет.</td></tr>
</tbody>
</table>
</div>
@@ -34,26 +63,44 @@
</div>
<div class="dlg-body">
<form class="form-stack" th:action="@{/admin/groups}" method="post">
<div><label class="label" for="name">Название</label>
<input class="input" id="name" name="name" placeholder="ПИ-11БО" required autofocus></div>
<div class="row g-3">
<div class="col-md-6"><label class="label" for="stream">Поток</label>
<input class="input" id="stream" name="stream" placeholder="ПИ" required autofocus></div>
<div class="col-md-6"><label class="label" for="degree">Степень</label>
<select class="input" id="degree" name="degree" required>
<option value="БО">БО</option>
<option value="МО">МО</option>
</select></div>
</div>
<div class="row g-3">
<div class="col-md-4"><label class="label" for="course">Курс обучения</label>
<input class="input" id="course" name="course" type="number" min="1" max="4" required></div>
<div class="col-md-4"><label class="label" for="number">Номер группы</label>
<input class="input" id="number" name="number" type="number" min="1" max="999" required></div>
<div class="col-md-4"><label class="label" for="enrollmentYear">Год набора</label>
<input class="input" id="enrollmentYear" name="enrollmentYear" type="number" min="2000" max="2100" required></div>
</div>
<p class="hint">Имя группы будет собрано автоматически: поток + курс + номер + степень, например ПИ-11БО.</p>
<button class="btn btn-primary" type="submit">Создать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<dialog id="modal-import-group" class="dlg">
<dialog id="modal-import-group" class="dlg dlg-lg">
<div class="dlg-header">
<h2 class="dlg-title">Импорт студентов</h2>
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="dlg-body">
<form class="form-stack" th:action="@{/admin/groups/import}" method="post" enctype="multipart/form-data">
<p class="text-sm text-muted">Файл .xls, .xlsx или .csv со столбцами: студенческий номер, ФИО, основа обучения. Логин — студенческий номер, пароль сгенерируется и покажется после импорта.</p>
<p class="text-sm text-muted">
Файл .xls, .xlsx или .csv. Столбцы:
<b>поток, курс, номер, степень, год набора, ФИО, №, база</b>.
Все строки — одна группа. Импорт атомарный: при ошибке данные не меняются.
</p>
<div><label class="label" for="file">Файл со списком</label>
<input class="input" id="file" name="file" type="file" accept=".xls,.xlsx,.csv" required autofocus></div>
<div><label class="label" for="groupName">Название группы (необязательно)</label>
<input class="input" id="groupName" name="name" placeholder="Возьмём из файла"></div>
<button class="btn btn-primary" type="submit">Импортировать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>

View File

@@ -19,6 +19,7 @@
<th class="th-sortable is-asc" title="ФИО — по возрастанию">ФИО</th>
<th>Логин</th>
<th>Группа</th>
<th>Статус</th>
<th class="text-right" data-nosort>Действия</th>
</tr>
</thead>
@@ -27,6 +28,10 @@
<td class="font-medium" th:text="${u.fullName}">—</td>
<td class="font-mono" th:text="${u.login}">—</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>
<span class="badge" th:classappend="${u.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
th:text="${u.status == 'ACTIVE' ? 'активен' : (u.status == 'EXPELLED' ? 'отчислен' : (u.status == 'ACADEMIC_LEAVE' ? 'академ' : (u.status == 'GRADUATED' ? 'выпущен' : 'архив')))}">—</span>
</td>
<td class="text-right">
<form class="inline-form" th:action="@{/admin/users/{id}/delete(id=${u.id})}" method="post" data-confirm="Удалить пользователя безвозвратно? Будут удалены аккаунт, группа, оценки с комментариями, сдачи с файлами и задания защит.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
@@ -35,7 +40,7 @@
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(users)}"><td colspan="4" class="empty">Никого не нашли.</td></tr>
<tr th:if="${#lists.isEmpty(users)}"><td colspan="5" class="empty">Никого не нашли.</td></tr>
</tbody>
</table>
</div>