feat(identity): группы-когорты, история и импорт v2
This commit is contained in:
10
README.md
10
README.md
@@ -98,11 +98,13 @@ cp .env.prod.example .env.prod # заполнить LMS_DB_PASSWORD
|
|||||||
администратора (`/setup`); далее — вход по логину и паролю.
|
администратора (`/setup`); далее — вход по логину и паролю.
|
||||||
|
|
||||||
- **Доступ**: JWT (RSA, ключи identity генерирует при первом старте и хранит в БД),
|
- **Доступ**: JWT (RSA, ключи identity генерирует при первом старте и хранит в БД),
|
||||||
проверка токена на gateway, роли ADMIN/STUDENT, CSRF на всех POST-формах,
|
проверка токена на gateway, роли ADMIN/TEACHER/STUDENT, CSRF на всех POST-формах,
|
||||||
session fixation-защита; вход студента с обязательной сменой пароля.
|
session fixation-защита; вход студента с обязательной сменой пароля.
|
||||||
- **Группы и студенты**: создание групп, импорт `.xls/.xlsx/.csv`, ручное
|
- **Группы и студенты**: группы-когорты (поток, курс, номер, степень, год набора),
|
||||||
добавление студента (генерация пароля) и «Добавить существующего» (перевод из
|
импорт `.xls/.xlsx/.csv` по шаблону `поток/курс/номер/степень/год набора/ФИО/№/база`,
|
||||||
другой группы), «Отчислить из группы», выгрузка логинов XLSX/CSV, сброс пароля.
|
ручное добавление студента (генерация пароля) и «Добавить существующего»
|
||||||
|
(перевод из другой группы), «Отчислить из группы», выгрузка логинов XLSX/CSV,
|
||||||
|
сброс пароля, история членства.
|
||||||
- **Лабораторные**: импорт ZIP (`lab.yaml` + `README.md` + `variants/*.md` +
|
- **Лабораторные**: импорт ZIP (`lab.yaml` + `README.md` + `variants/*.md` +
|
||||||
`assets/`); создание через UI с произвольным числом уровней; единая форма
|
`assets/`); создание через UI с произвольным числом уровней; единая форма
|
||||||
настроек с одной кнопкой «Сохранить всё»; статусы draft/published
|
настроек с одной кнопкой «Сохранить всё»; статусы draft/published
|
||||||
|
|||||||
@@ -11,16 +11,27 @@
|
|||||||
|
|
||||||
## Шаг 2. Группы и студенты
|
## Шаг 2. Группы и студенты
|
||||||
|
|
||||||
**Группы → Импорт** — загрузите `.xls`/`.xlsx`/`.csv` со списком группы
|
**Группы → Импорт** — загрузите `.xls`/`.xlsx`/`.csv` со списком группы.
|
||||||
(столбцы: «Студенческий номер», «Фамилия Имя Отчество», «Основа обучения»).
|
Обязательные столбцы:
|
||||||
Название группы берётся из файла или указывается при импорте. Логином студента
|
|
||||||
становится студенческий номер, пароль генерируется (временный, с обязательной
|
```text
|
||||||
сменой при первом входе).
|
поток | курс | номер | степень | год набора | ФИО | № | база
|
||||||
|
```
|
||||||
|
|
||||||
|
Все строки файла должны относиться к одной группе. Группа создаётся по полям
|
||||||
|
(имя собирается автоматически, например `ПИ-11БО`). Логином студента становится
|
||||||
|
номер (`№`), пароль генерируется (временный, с обязательной сменой при первом
|
||||||
|
входе). Ключ слияния — `student_no`: повторный импорт обновляет студента, а не
|
||||||
|
дублирует.
|
||||||
|
|
||||||
|
Импорт атомарный: при ошибке в любой строке данные не меняются, а в интерфейсе
|
||||||
|
показывается отчёт с номерами строк.
|
||||||
|
|
||||||
Дальше по каждому студенту при необходимости:
|
Дальше по каждому студенту при необходимости:
|
||||||
* **«Сбросить пароль»** — выдать новый временный пароль;
|
* **«Сбросить пароль»** — выдать новый временный пароль;
|
||||||
* **«Добавить существующего»** — перевести студента из другой группы;
|
* **«Добавить существующего»** — перевести студента из другой группы;
|
||||||
* **«Отчислить из группы»** — убрать из группы (аккаунт и данные сохраняются).
|
* **«Отчислить из группы»** — убрать из группы, студент получает статус
|
||||||
|
«отчислен»; аккаунт, сдачи и оценки сохраняются.
|
||||||
|
|
||||||
Выгрузить логины: **«Логины XLSX» / «CSV»**.
|
Выгрузить логины: **«Логины XLSX» / «CSV»**.
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ classDiagram
|
|||||||
+String login
|
+String login
|
||||||
+String passwordHash
|
+String passwordHash
|
||||||
+LmsRole role
|
+LmsRole role
|
||||||
|
+AccountStatus status
|
||||||
|
+Instant archivedAt
|
||||||
+Boolean mustChangePassword
|
+Boolean mustChangePassword
|
||||||
+String tempPassword
|
+String tempPassword
|
||||||
+Instant createdAt
|
+Instant createdAt
|
||||||
@@ -25,11 +27,19 @@ classDiagram
|
|||||||
+String middleName
|
+String middleName
|
||||||
+String basis
|
+String basis
|
||||||
+String note
|
+String note
|
||||||
|
+StudentStatus status
|
||||||
|
+Short enrollmentYear
|
||||||
+String fullName
|
+String fullName
|
||||||
}
|
}
|
||||||
class GroupEntity {
|
class GroupEntity {
|
||||||
+UUID id
|
+UUID id
|
||||||
+String name
|
+String name
|
||||||
|
+String stream
|
||||||
|
+Short course
|
||||||
|
+Short number
|
||||||
|
+String degree
|
||||||
|
+Short enrollmentYear
|
||||||
|
+GroupStatus status
|
||||||
+Int taskDurationMin
|
+Int taskDurationMin
|
||||||
+Instant createdAt
|
+Instant createdAt
|
||||||
}
|
}
|
||||||
@@ -37,6 +47,17 @@ classDiagram
|
|||||||
+UUID id
|
+UUID id
|
||||||
+Instant enrolledAt
|
+Instant enrolledAt
|
||||||
}
|
}
|
||||||
|
class MembershipHistory {
|
||||||
|
+UUID id
|
||||||
|
+UUID studentId
|
||||||
|
+UUID fromGroupId
|
||||||
|
+UUID toGroupId
|
||||||
|
+MembershipAction action
|
||||||
|
+String reason
|
||||||
|
+UUID actorAccountId
|
||||||
|
+UUID promotionRunId
|
||||||
|
+Instant at
|
||||||
|
}
|
||||||
class Setting {
|
class Setting {
|
||||||
+String key
|
+String key
|
||||||
+String value
|
+String value
|
||||||
@@ -44,12 +65,15 @@ classDiagram
|
|||||||
class LmsRole {
|
class LmsRole {
|
||||||
<<enumeration>>
|
<<enumeration>>
|
||||||
ADMIN
|
ADMIN
|
||||||
|
TEACHER
|
||||||
STUDENT
|
STUDENT
|
||||||
}
|
}
|
||||||
|
|
||||||
Account "1" --> "1" Student : @MapsId account_id
|
Account "1" --> "1" Student : @MapsId account_id
|
||||||
GroupEntity "1" --> "*" Enrollment : group_id
|
GroupEntity "1" --> "*" Enrollment : group_id
|
||||||
Student "1" --> "*" Enrollment : student_id
|
Student "1" --> "*" Enrollment : student_id
|
||||||
|
Student "1" --> "*" MembershipHistory : student_id
|
||||||
|
GroupEntity "1" --> "*" MembershipHistory : from/to_group_id
|
||||||
Account --> LmsRole : role
|
Account --> LmsRole : role
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -68,20 +92,28 @@ classDiagram
|
|||||||
class GroupService {
|
class GroupService {
|
||||||
+list() List~GroupSummary~
|
+list() List~GroupSummary~
|
||||||
+create(CreateGroupRequest) GroupSummary
|
+create(CreateGroupRequest) GroupSummary
|
||||||
+createStudent(UUID, CreateStudentRequest) Credential
|
+createStudent(UUID, CreateStudentRequest, UUID) Credential
|
||||||
+candidates(UUID) List~StudentCandidate~
|
+candidates(UUID) List~StudentCandidate~
|
||||||
+enroll(UUID, UUID) GroupDetail
|
+enroll(UUID, UUID, UUID) GroupDetail
|
||||||
+unenroll(UUID, UUID) GroupDetail
|
+unenroll(UUID, UUID, UUID) GroupDetail
|
||||||
+import(String, String, ByteArray) ImportResult
|
|
||||||
+groupOfStudent(UUID) GroupSummary
|
+groupOfStudent(UUID) GroupSummary
|
||||||
+detail(UUID) GroupDetail
|
+detail(UUID) GroupDetail
|
||||||
+resetPassword(UUID) ResetPasswordResponse
|
+resetPassword(UUID) ResetPasswordResponse
|
||||||
+credentials(UUID, String) ExportFile
|
+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 AccountRepository
|
||||||
class GroupRepository
|
class GroupRepository
|
||||||
class EnrollmentRepository
|
class EnrollmentRepository
|
||||||
class StudentRepository
|
class StudentRepository
|
||||||
|
class MembershipHistoryRepository
|
||||||
class SettingRepository
|
class SettingRepository
|
||||||
class StudentImportParser {
|
class StudentImportParser {
|
||||||
+parse(String, ByteArray) ParsedGroup
|
+parse(String, ByteArray) ParsedGroup
|
||||||
@@ -96,6 +128,7 @@ classDiagram
|
|||||||
|
|
||||||
AuthController --> AuthService
|
AuthController --> AuthService
|
||||||
GroupController --> GroupService
|
GroupController --> GroupService
|
||||||
|
GroupController --> GroupImportService
|
||||||
StudentController --> GroupService
|
StudentController --> GroupService
|
||||||
AuthService --> AccountRepository
|
AuthService --> AccountRepository
|
||||||
AuthService --> StudentRepository
|
AuthService --> StudentRepository
|
||||||
@@ -103,7 +136,14 @@ classDiagram
|
|||||||
GroupService --> EnrollmentRepository
|
GroupService --> EnrollmentRepository
|
||||||
GroupService --> StudentRepository
|
GroupService --> StudentRepository
|
||||||
GroupService --> AccountRepository
|
GroupService --> AccountRepository
|
||||||
GroupService --> StudentImportParser
|
GroupService --> MembershipHistoryRepository
|
||||||
|
GroupService --> GroupCohortResolver
|
||||||
|
GroupImportService --> GroupRepository
|
||||||
|
GroupImportService --> EnrollmentRepository
|
||||||
|
GroupImportService --> StudentRepository
|
||||||
|
GroupImportService --> AccountRepository
|
||||||
|
GroupImportService --> MembershipHistoryRepository
|
||||||
|
GroupImportService --> StudentImportParser
|
||||||
GroupService --> CredentialsExporter
|
GroupService --> CredentialsExporter
|
||||||
GroupService --> Passwords
|
GroupService --> Passwords
|
||||||
```
|
```
|
||||||
@@ -516,6 +556,8 @@ erDiagram
|
|||||||
varchar login UK
|
varchar login UK
|
||||||
varchar password_hash
|
varchar password_hash
|
||||||
varchar role
|
varchar role
|
||||||
|
varchar status
|
||||||
|
timestamptz archived_at
|
||||||
boolean must_change_password
|
boolean must_change_password
|
||||||
varchar temp_password
|
varchar temp_password
|
||||||
timestamptz created_at
|
timestamptz created_at
|
||||||
@@ -529,10 +571,18 @@ erDiagram
|
|||||||
varchar middle_name
|
varchar middle_name
|
||||||
varchar basis
|
varchar basis
|
||||||
text note
|
text note
|
||||||
|
varchar status
|
||||||
|
smallint enrollment_year
|
||||||
}
|
}
|
||||||
GROUPS {
|
GROUPS {
|
||||||
uuid id PK
|
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
|
int task_duration_min
|
||||||
timestamptz created_at
|
timestamptz created_at
|
||||||
}
|
}
|
||||||
@@ -542,14 +592,27 @@ erDiagram
|
|||||||
uuid student_id FK
|
uuid student_id FK
|
||||||
timestamptz enrolled_at
|
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 {
|
SETTINGS {
|
||||||
varchar key PK
|
varchar key PK
|
||||||
varchar value
|
varchar value
|
||||||
}
|
}
|
||||||
|
|
||||||
ACCOUNTS ||--|| STUDENTS : "account_id"
|
ACCOUNTS ||--o| STUDENTS : "account_id"
|
||||||
GROUPS ||--o{ ENROLLMENTS : "group_id"
|
GROUPS ||--o{ ENROLLMENTS : "group_id"
|
||||||
STUDENTS ||--o{ ENROLLMENTS : "student_id"
|
STUDENTS ||--o{ ENROLLMENTS : "student_id"
|
||||||
|
STUDENTS ||--o{ MEMBERSHIP_HISTORY : "student_id"
|
||||||
|
GROUPS ||--o{ MEMBERSHIP_HISTORY : "from/to_group_id"
|
||||||
|
|
||||||
SEMESTERS {
|
SEMESTERS {
|
||||||
uuid id PK
|
uuid id PK
|
||||||
|
|||||||
95
docs/11-stage1-report.md
Normal file
95
docs/11-stage1-report.md
Normal 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 курсов.
|
||||||
@@ -2,6 +2,7 @@ package ru.ysu.lms.security
|
|||||||
|
|
||||||
enum class LmsRole {
|
enum class LmsRole {
|
||||||
ADMIN,
|
ADMIN,
|
||||||
|
TEACHER,
|
||||||
STUDENT,
|
STUDENT,
|
||||||
;
|
;
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import ru.ysu.lms.common.ConflictException
|
|||||||
import ru.ysu.lms.common.NotFoundException
|
import ru.ysu.lms.common.NotFoundException
|
||||||
import ru.ysu.lms.common.UnauthorizedException
|
import ru.ysu.lms.common.UnauthorizedException
|
||||||
import ru.ysu.lms.identity.domain.Account
|
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.AccountRepository
|
||||||
import ru.ysu.lms.identity.repo.StudentRepository
|
import ru.ysu.lms.identity.repo.StudentRepository
|
||||||
import ru.ysu.lms.security.JwtProperties
|
import ru.ysu.lms.security.JwtProperties
|
||||||
@@ -61,10 +63,22 @@ class AuthService(
|
|||||||
backoff.recordFailure(login)
|
backoff.recordFailure(login)
|
||||||
throw UnauthorizedException("Неверный логин или пароль")
|
throw UnauthorizedException("Неверный логин или пароль")
|
||||||
}
|
}
|
||||||
|
ensureLoginAllowed(account)
|
||||||
backoff.reset(login)
|
backoff.reset(login)
|
||||||
return toResponse(account, displayName(account))
|
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)
|
@Transactional(readOnly = true)
|
||||||
fun me(accountId: UUID): MeResponse {
|
fun me(accountId: UUID): MeResponse {
|
||||||
val account =
|
val account =
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ class Account(
|
|||||||
var mustChangePassword: Boolean = true,
|
var mustChangePassword: Boolean = true,
|
||||||
@Column(name = "temp_password", length = 64)
|
@Column(name = "temp_password", length = 64)
|
||||||
var tempPassword: String? = null,
|
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)
|
@Column(name = "created_at", nullable = false)
|
||||||
var createdAt: Instant = Instant.now(),
|
var createdAt: Instant = Instant.now(),
|
||||||
@Column(name = "updated_at", nullable = false)
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package ru.ysu.lms.identity.domain
|
||||||
|
|
||||||
|
enum class AccountStatus {
|
||||||
|
ACTIVE,
|
||||||
|
ARCHIVED,
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package ru.ysu.lms.identity.domain
|
|||||||
|
|
||||||
import jakarta.persistence.Column
|
import jakarta.persistence.Column
|
||||||
import jakarta.persistence.Entity
|
import jakarta.persistence.Entity
|
||||||
|
import jakarta.persistence.EnumType
|
||||||
|
import jakarta.persistence.Enumerated
|
||||||
import jakarta.persistence.Id
|
import jakarta.persistence.Id
|
||||||
import jakarta.persistence.Table
|
import jakarta.persistence.Table
|
||||||
import ru.ysu.lms.common.Ids
|
import ru.ysu.lms.common.Ids
|
||||||
@@ -13,8 +15,19 @@ import java.util.UUID
|
|||||||
class GroupEntity(
|
class GroupEntity(
|
||||||
@Id
|
@Id
|
||||||
var id: UUID = Ids.v7(),
|
var id: UUID = Ids.v7(),
|
||||||
@Column(nullable = false, unique = true, length = 100)
|
@Column(nullable = false, length = 100)
|
||||||
var name: String = "",
|
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)
|
@Column(name = "task_duration_min", nullable = false)
|
||||||
var taskDurationMin: Int = 30,
|
var taskDurationMin: Int = 30,
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package ru.ysu.lms.identity.domain
|
||||||
|
|
||||||
|
enum class GroupStatus {
|
||||||
|
ACTIVE,
|
||||||
|
GRADUATED,
|
||||||
|
ARCHIVED,
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package ru.ysu.lms.identity.domain
|
||||||
|
|
||||||
|
enum class MembershipAction {
|
||||||
|
ENROLL,
|
||||||
|
TRANSFER,
|
||||||
|
ACADEMIC,
|
||||||
|
RETURN,
|
||||||
|
GRADUATE,
|
||||||
|
EXPELL,
|
||||||
|
PROMOTE,
|
||||||
|
IMPORT,
|
||||||
|
ARCHIVE,
|
||||||
|
RESTORE,
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
@@ -2,6 +2,8 @@ package ru.ysu.lms.identity.domain
|
|||||||
|
|
||||||
import jakarta.persistence.Column
|
import jakarta.persistence.Column
|
||||||
import jakarta.persistence.Entity
|
import jakarta.persistence.Entity
|
||||||
|
import jakarta.persistence.EnumType
|
||||||
|
import jakarta.persistence.Enumerated
|
||||||
import jakarta.persistence.FetchType
|
import jakarta.persistence.FetchType
|
||||||
import jakarta.persistence.Id
|
import jakarta.persistence.Id
|
||||||
import jakarta.persistence.JoinColumn
|
import jakarta.persistence.JoinColumn
|
||||||
@@ -28,6 +30,11 @@ class Student(
|
|||||||
var basis: String = "",
|
var basis: String = "",
|
||||||
@Column(name = "note", nullable = false)
|
@Column(name = "note", nullable = false)
|
||||||
var note: String = "",
|
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)
|
@OneToOne(fetch = FetchType.LAZY)
|
||||||
@MapsId
|
@MapsId
|
||||||
@JoinColumn(name = "account_id")
|
@JoinColumn(name = "account_id")
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package ru.ysu.lms.identity.domain
|
||||||
|
|
||||||
|
enum class StudentStatus {
|
||||||
|
ACTIVE,
|
||||||
|
ACADEMIC_LEAVE,
|
||||||
|
EXPELLED,
|
||||||
|
GRADUATED,
|
||||||
|
ARCHIVED,
|
||||||
|
}
|
||||||
@@ -9,5 +9,15 @@ interface GroupRepository : JpaRepository<GroupEntity, UUID> {
|
|||||||
|
|
||||||
fun existsByName(name: String): Boolean
|
fun existsByName(name: String): Boolean
|
||||||
|
|
||||||
|
fun findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
|
||||||
|
stream: String,
|
||||||
|
degree: String,
|
||||||
|
number: Short,
|
||||||
|
course: Short,
|
||||||
|
enrollmentYear: Short,
|
||||||
|
): GroupEntity?
|
||||||
|
|
||||||
fun findAllByOrderByNameAsc(): List<GroupEntity>
|
fun findAllByOrderByNameAsc(): List<GroupEntity>
|
||||||
|
|
||||||
|
fun findAllByOrderByEnrollmentYearDescNameAsc(): List<GroupEntity>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import java.util.UUID
|
|||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
class GroupController(
|
class GroupController(
|
||||||
private val groupService: GroupService,
|
private val groupService: GroupService,
|
||||||
|
private val importService: GroupImportService,
|
||||||
) {
|
) {
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun list(): List<GroupSummary> = groupService.list()
|
fun list(): List<GroupSummary> = groupService.list()
|
||||||
@@ -39,17 +40,18 @@ class GroupController(
|
|||||||
fun createStudent(
|
fun createStudent(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
@Valid @RequestBody body: CreateStudentRequest,
|
@Valid @RequestBody body: CreateStudentRequest,
|
||||||
): Credential = groupService.createStudent(id, body)
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
): Credential = groupService.createStudent(id, body, UUID.fromString(jwt.subject))
|
||||||
|
|
||||||
@PostMapping("/import")
|
@PostMapping("/import")
|
||||||
fun import(
|
fun import(
|
||||||
@RequestParam("file") file: MultipartFile,
|
@RequestParam("file") file: MultipartFile,
|
||||||
@RequestParam(name = "name", required = false) name: String?,
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
): ImportResult {
|
): ImportResult {
|
||||||
if (file.isEmpty) {
|
if (file.isEmpty) {
|
||||||
throw BadRequestException("Выберите файл со списком группы")
|
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(
|
fun enroll(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
@PathVariable studentId: 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}")
|
@DeleteMapping("/{id}/students/{studentId}")
|
||||||
fun unenroll(
|
fun unenroll(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
@PathVariable studentId: UUID,
|
@PathVariable studentId: UUID,
|
||||||
): GroupDetail = groupService.unenroll(id, studentId)
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
): GroupDetail = groupService.unenroll(id, studentId, UUID.fromString(jwt.subject))
|
||||||
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,37 +10,43 @@ import ru.ysu.lms.identity.auth.Passwords
|
|||||||
import ru.ysu.lms.identity.domain.Account
|
import ru.ysu.lms.identity.domain.Account
|
||||||
import ru.ysu.lms.identity.domain.Enrollment
|
import ru.ysu.lms.identity.domain.Enrollment
|
||||||
import ru.ysu.lms.identity.domain.GroupEntity
|
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.Student
|
||||||
|
import ru.ysu.lms.identity.domain.StudentStatus
|
||||||
import ru.ysu.lms.identity.repo.AccountRepository
|
import ru.ysu.lms.identity.repo.AccountRepository
|
||||||
import ru.ysu.lms.identity.repo.EnrollmentRepository
|
import ru.ysu.lms.identity.repo.EnrollmentRepository
|
||||||
import ru.ysu.lms.identity.repo.GroupRepository
|
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.identity.repo.StudentRepository
|
||||||
import ru.ysu.lms.security.LmsRole
|
import ru.ysu.lms.security.LmsRole
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
@Service
|
@Service
|
||||||
class GroupService(
|
class GroupService(
|
||||||
private val groups: GroupRepository,
|
private val groups: GroupRepository,
|
||||||
private val enrollments: EnrollmentRepository,
|
private val enrollments: EnrollmentRepository,
|
||||||
private val students: StudentRepository,
|
private val students: StudentRepository,
|
||||||
private val accounts: AccountRepository,
|
private val accounts: AccountRepository,
|
||||||
|
private val history: MembershipHistoryRepository,
|
||||||
private val passwordEncoder: PasswordEncoder,
|
private val passwordEncoder: PasswordEncoder,
|
||||||
private val parser: StudentImportParser,
|
private val cohortResolver: GroupCohortResolver,
|
||||||
) {
|
) {
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun list(): List<GroupSummary> = groups.findAllByOrderByNameAsc().map { summary(it) }
|
fun list(): List<GroupSummary> = groups.findAllByOrderByEnrollmentYearDescNameAsc().map { summary(it) }
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createStudent(
|
fun createStudent(
|
||||||
groupId: UUID,
|
groupId: UUID,
|
||||||
request: CreateStudentRequest,
|
request: CreateStudentRequest,
|
||||||
|
actorAccountId: UUID? = null,
|
||||||
): Credential {
|
): Credential {
|
||||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
val group = activeGroup(groupId)
|
||||||
val studentNo = request.studentNo.trim()
|
val studentNo = request.studentNo.trim()
|
||||||
if (studentNo.isBlank()) throw BadRequestException("Студенческий номер не может быть пустым")
|
if (studentNo.isBlank()) throw BadRequestException("Студенческий номер не может быть пустым")
|
||||||
if (students.existsByStudentNo(studentNo)) {
|
ensureStudentNumberAvailable(studentNo)
|
||||||
throw ConflictException("Студент с номером $studentNo уже существует")
|
|
||||||
}
|
|
||||||
val password = Passwords.generate()
|
val password = Passwords.generate()
|
||||||
val account =
|
val account =
|
||||||
accounts.save(
|
accounts.save(
|
||||||
@@ -52,7 +58,6 @@ class GroupService(
|
|||||||
tempPassword = password,
|
tempPassword = password,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Student.id — это account_id через @MapsId: ассоциацию задаём до persist.
|
|
||||||
val student =
|
val student =
|
||||||
students.save(
|
students.save(
|
||||||
Student(
|
Student(
|
||||||
@@ -62,10 +67,19 @@ class GroupService(
|
|||||||
middleName = request.middleName.trim(),
|
middleName = request.middleName.trim(),
|
||||||
basis = request.basis.trim(),
|
basis = request.basis.trim(),
|
||||||
note = request.note.trim(),
|
note = request.note.trim(),
|
||||||
|
status = StudentStatus.ACTIVE,
|
||||||
|
enrollmentYear = group.enrollmentYear,
|
||||||
account = account,
|
account = account,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
enrollments.save(Enrollment(group = group, student = student))
|
enrollments.save(Enrollment(group = group, student = student))
|
||||||
|
recordHistory(
|
||||||
|
studentNo = student.accountId!!,
|
||||||
|
fromGroupId = null,
|
||||||
|
toGroupId = group.id,
|
||||||
|
action = MembershipAction.ENROLL,
|
||||||
|
actorAccountId = actorAccountId,
|
||||||
|
)
|
||||||
return Credential(
|
return Credential(
|
||||||
id = account.id.toString(),
|
id = account.id.toString(),
|
||||||
studentNo = studentNo,
|
studentNo = studentNo,
|
||||||
@@ -76,11 +90,31 @@ class GroupService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun create(request: CreateGroupRequest): GroupSummary {
|
fun create(request: CreateGroupRequest): GroupSummary {
|
||||||
val name = request.name.trim()
|
val cohort = cohortResolver.resolve(request)
|
||||||
if (groups.existsByName(name)) {
|
val existing =
|
||||||
throw ConflictException("Группа «$name» уже существует")
|
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(
|
fun enroll(
|
||||||
groupId: UUID,
|
groupId: UUID,
|
||||||
studentId: UUID,
|
studentId: UUID,
|
||||||
|
actorAccountId: UUID? = null,
|
||||||
): GroupDetail {
|
): GroupDetail {
|
||||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
val group = activeGroup(groupId)
|
||||||
val student = students.findById(studentId).orElseThrow { NotFoundException("Студент не найден") }
|
val student = students.findById(studentId).orElseThrow { NotFoundException("Студент не найден") }
|
||||||
val existing = enrollments.findByStudentAccountId(studentId)
|
val existing = enrollments.findByStudentAccountId(studentId)
|
||||||
if (existing != null) {
|
val fromGroupId = existing?.group?.id
|
||||||
if (existing.group?.id == group.id) {
|
if (fromGroupId == group.id) {
|
||||||
throw ConflictException("Студент уже в этой группе")
|
throw ConflictException("Студент уже в этой группе")
|
||||||
}
|
}
|
||||||
// unique-констрейт на student_id: переводим перепривязкой, без delete+insert
|
if (existing == null) {
|
||||||
|
enrollments.save(Enrollment(group = group, student = student))
|
||||||
|
} else {
|
||||||
existing.group = group
|
existing.group = group
|
||||||
enrollments.save(existing)
|
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)
|
return detail(groupId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Отчислить из группы (студент и его аккаунт сохраняются). */
|
/** Убрать студента из группы; аккаунт и данные сохраняются. */
|
||||||
@Transactional
|
@Transactional
|
||||||
fun unenroll(
|
fun unenroll(
|
||||||
groupId: UUID,
|
groupId: UUID,
|
||||||
studentId: UUID,
|
studentId: UUID,
|
||||||
|
actorAccountId: UUID? = null,
|
||||||
): GroupDetail {
|
): GroupDetail {
|
||||||
val enrollment =
|
val enrollment =
|
||||||
enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
|
enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
|
||||||
?: throw NotFoundException("Студент не входит в эту группу")
|
?: throw NotFoundException("Студент не входит в эту группу")
|
||||||
enrollments.delete(enrollment)
|
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)
|
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)
|
@Transactional(readOnly = true)
|
||||||
fun groupOfStudent(accountId: UUID): GroupSummary? {
|
fun groupOfStudent(accountId: UUID): GroupSummary? {
|
||||||
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null
|
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null
|
||||||
return GroupSummary(
|
return summary(group)
|
||||||
id = group.id.toString(),
|
|
||||||
name = group.name,
|
|
||||||
studentCount = enrollments.findAllByGroupId(group.id).size.toLong(),
|
|
||||||
taskDurationMin = group.taskDurationMin,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -255,11 +212,24 @@ class GroupService(
|
|||||||
studentNo = student.studentNo,
|
studentNo = student.studentNo,
|
||||||
fullName = student.fullName,
|
fullName = student.fullName,
|
||||||
basis = student.basis,
|
basis = student.basis,
|
||||||
|
status = student.status.name,
|
||||||
|
enrollmentYear = student.enrollmentYear,
|
||||||
mustChangePassword = account.mustChangePassword,
|
mustChangePassword = account.mustChangePassword,
|
||||||
tempPassword = account.tempPassword,
|
tempPassword = account.tempPassword,
|
||||||
)
|
)
|
||||||
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
|
}.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,
|
studentNo = s.studentNo,
|
||||||
fullName = s.fullName,
|
fullName = s.fullName,
|
||||||
basis = s.basis,
|
basis = s.basis,
|
||||||
|
status = s.status.name,
|
||||||
|
enrollmentYear = s.enrollmentYear,
|
||||||
groupId = groupInfo?.first?.toString(),
|
groupId = groupInfo?.first?.toString(),
|
||||||
groupName = groupInfo?.second,
|
groupName = groupInfo?.second,
|
||||||
)
|
)
|
||||||
@@ -341,27 +313,69 @@ class GroupService(
|
|||||||
): ExportFile {
|
): ExportFile {
|
||||||
val detail = detail(groupId)
|
val detail = detail(groupId)
|
||||||
val safeName = detail.name.replace(Regex("[^\\p{L}\\p{N}._-]+"), "_")
|
val safeName = detail.name.replace(Regex("[^\\p{L}\\p{N}._-]+"), "_")
|
||||||
|
val year = detail.enrollmentYear?.let { " ($it)" }.orEmpty()
|
||||||
return when (format.lowercase()) {
|
return when (format.lowercase()) {
|
||||||
"csv" ->
|
"csv" ->
|
||||||
ExportFile(
|
ExportFile(
|
||||||
bytes = CredentialsExporter.csv(detail.students),
|
bytes = CredentialsExporter.csv(detail.students),
|
||||||
filename = "credentials_$safeName.csv",
|
filename = "credentials_$safeName$year.csv",
|
||||||
contentType = "text/csv; charset=utf-8",
|
contentType = "text/csv; charset=utf-8",
|
||||||
)
|
)
|
||||||
"xlsx" ->
|
"xlsx" ->
|
||||||
ExportFile(
|
ExportFile(
|
||||||
bytes = CredentialsExporter.xlsx(detail.students),
|
bytes = CredentialsExporter.xlsx(detail.students),
|
||||||
filename = "credentials_$safeName.xlsx",
|
filename = "credentials_$safeName$year.xlsx",
|
||||||
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
)
|
)
|
||||||
else -> throw BadRequestException("Формат должен быть xlsx или csv")
|
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 =
|
private fun summary(group: GroupEntity): GroupSummary =
|
||||||
GroupSummary(
|
GroupSummary(
|
||||||
id = group.id.toString(),
|
id = group.id.toString(),
|
||||||
name = group.name,
|
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),
|
studentCount = enrollments.countByGroupId(group.id),
|
||||||
taskDurationMin = group.taskDurationMin,
|
taskDurationMin = group.taskDurationMin,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,13 +5,23 @@ import jakarta.validation.constraints.NotBlank
|
|||||||
data class GroupSummary(
|
data class GroupSummary(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: 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 studentCount: Long,
|
||||||
val taskDurationMin: Int,
|
val taskDurationMin: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class CreateGroupRequest(
|
data class CreateGroupRequest(
|
||||||
@field:NotBlank(message = "Укажите название группы")
|
val name: String? = null,
|
||||||
val name: String,
|
val stream: String? = null,
|
||||||
|
val course: Short? = null,
|
||||||
|
val number: Short? = null,
|
||||||
|
val degree: String? = null,
|
||||||
|
val enrollmentYear: Short? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class CreateStudentRequest(
|
data class CreateStudentRequest(
|
||||||
@@ -30,6 +40,8 @@ data class StudentRow(
|
|||||||
val studentNo: String,
|
val studentNo: String,
|
||||||
val fullName: String,
|
val fullName: String,
|
||||||
val basis: String,
|
val basis: String,
|
||||||
|
val status: String = "ACTIVE",
|
||||||
|
val enrollmentYear: Short? = null,
|
||||||
val mustChangePassword: Boolean,
|
val mustChangePassword: Boolean,
|
||||||
val tempPassword: String?,
|
val tempPassword: String?,
|
||||||
)
|
)
|
||||||
@@ -37,6 +49,12 @@ data class StudentRow(
|
|||||||
data class GroupDetail(
|
data class GroupDetail(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: 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 taskDurationMin: Int,
|
||||||
val students: List<StudentRow>,
|
val students: List<StudentRow>,
|
||||||
)
|
)
|
||||||
@@ -49,10 +67,17 @@ data class Credential(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class ImportResult(
|
data class ImportResult(
|
||||||
val groupId: String,
|
val applied: Boolean = true,
|
||||||
val groupName: String,
|
val groupId: String? = null,
|
||||||
val added: List<Credential>,
|
val groupName: String? = null,
|
||||||
val updated: List<String>,
|
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(
|
data class ResetPasswordResponse(
|
||||||
@@ -75,6 +100,8 @@ data class AdminUser(
|
|||||||
val studentNo: String,
|
val studentNo: String,
|
||||||
val fullName: String,
|
val fullName: String,
|
||||||
val basis: String,
|
val basis: String,
|
||||||
|
val status: String = "ACTIVE",
|
||||||
|
val enrollmentYear: Short? = null,
|
||||||
val groupId: String?,
|
val groupId: String?,
|
||||||
val groupName: String?,
|
val groupName: String?,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,11 +18,45 @@ data class ParsedStudent(
|
|||||||
get() = listOf(lastName, firstName, middleName).filter { it.isNotBlank() }.joinToString(" ")
|
get() = listOf(lastName, firstName, middleName).filter { it.isNotBlank() }.joinToString(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ParsedGroup(
|
data class ImportRowError(
|
||||||
val groupName: String,
|
val row: Int,
|
||||||
val students: List<ParsedStudent>,
|
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
|
@Component
|
||||||
class StudentImportParser {
|
class StudentImportParser {
|
||||||
fun parse(
|
fun parse(
|
||||||
@@ -30,11 +64,18 @@ class StudentImportParser {
|
|||||||
bytes: ByteArray,
|
bytes: ByteArray,
|
||||||
): ParsedGroup {
|
): ParsedGroup {
|
||||||
val lower = filename.lowercase()
|
val lower = filename.lowercase()
|
||||||
return when {
|
val rows =
|
||||||
lower.endsWith(".csv") -> parseRows(filename, parseCsv(bytes))
|
when {
|
||||||
lower.endsWith(".xls") || lower.endsWith(".xlsx") -> parseRows(filename, parseExcel(bytes))
|
lower.endsWith(".csv") -> parseCsv(bytes)
|
||||||
else -> throw BadRequestException("Поддерживаются файлы .xls, .xlsx и .csv")
|
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>> {
|
private fun parseExcel(bytes: ByteArray): List<List<String>> {
|
||||||
@@ -55,9 +96,7 @@ class StudentImportParser {
|
|||||||
private fun parseCsv(bytes: ByteArray): List<List<String>> {
|
private fun parseCsv(bytes: ByteArray): List<List<String>> {
|
||||||
val text = bytes.toString(Charsets.UTF_8).removePrefix("\uFEFF")
|
val text = bytes.toString(Charsets.UTF_8).removePrefix("\uFEFF")
|
||||||
val lines = text.split(Regex("\r?\n")).filter { it.isNotBlank() }
|
val lines = text.split(Regex("\r?\n")).filter { it.isNotBlank() }
|
||||||
if (lines.isEmpty()) {
|
if (lines.isEmpty()) throw BadRequestException("Файл пуст")
|
||||||
throw BadRequestException("Файл пуст")
|
|
||||||
}
|
|
||||||
val delimiter = detectDelimiter(lines.first())
|
val delimiter = detectDelimiter(lines.first())
|
||||||
return lines.map { splitCsvLine(it, delimiter) }
|
return lines.map { splitCsvLine(it, delimiter) }
|
||||||
}
|
}
|
||||||
@@ -89,101 +128,228 @@ class StudentImportParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun parseRows(
|
private fun parseRows(
|
||||||
filename: String,
|
|
||||||
rows: List<List<String>>,
|
rows: List<List<String>>,
|
||||||
|
headerIndex: Int,
|
||||||
|
columns: Map<String, Int>,
|
||||||
): ParsedGroup {
|
): ParsedGroup {
|
||||||
val headerIndex = requireHeader(rows)
|
val errors = mutableListOf<ImportRowError>()
|
||||||
val columns = requireColumns(rows[headerIndex])
|
val students = mutableListOf<ParsedStudent>()
|
||||||
val students = rows.drop(headerIndex + 1).mapNotNull { parseStudent(it, columns) }
|
val seenStudentNos = mutableMapOf<String, Int>()
|
||||||
if (students.isEmpty()) {
|
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("В таблице не найдено ни одного студента")
|
throw BadRequestException("В таблице не найдено ни одного студента")
|
||||||
}
|
}
|
||||||
return ParsedGroup(groupNameFrom(filename, rows), students)
|
return ParsedGroup(cohort = cohort, students = students, errors = errors)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requireHeader(rows: List<List<String>>): Int {
|
private fun parseCohort(
|
||||||
val headerIndex = rows.indexOfFirst { row -> row.any { isStudentHeader(it) } }
|
row: List<String>,
|
||||||
if (headerIndex < 0) {
|
columns: Map<String, Int>,
|
||||||
throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.")
|
rowNumber: Int,
|
||||||
}
|
errors: MutableList<ImportRowError>,
|
||||||
return headerIndex
|
): 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> {
|
private fun validateRequiredCohortFields(
|
||||||
val columns = mapColumns(header)
|
fields: CohortFields,
|
||||||
if (columns["studentNo"] == null) {
|
rowNumber: Int,
|
||||||
throw BadRequestException("Не найден столбец «Студенческий номер»")
|
errors: MutableList<ImportRowError>,
|
||||||
}
|
) {
|
||||||
return columns
|
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(
|
private fun parseStudent(
|
||||||
row: List<String>,
|
row: List<String>,
|
||||||
columns: Map<String, Int>,
|
columns: Map<String, Int>,
|
||||||
|
rowNumber: Int,
|
||||||
|
errors: MutableList<ImportRowError>,
|
||||||
|
seenStudentNos: MutableMap<String, Int>,
|
||||||
): ParsedStudent? {
|
): ParsedStudent? {
|
||||||
fun cell(key: String): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
|
val before = errors.size
|
||||||
val studentNo = normalizeNumber(cell("studentNo"))
|
val studentNo = normalizeNumber(cell(row, columns, "studentNo"))
|
||||||
val lastName = cell("lastName")
|
|
||||||
if (studentNo.isBlank() && lastName.isBlank()) return null
|
|
||||||
if (studentNo.isBlank()) {
|
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(
|
return ParsedStudent(
|
||||||
studentNo = studentNo,
|
studentNo = studentNo,
|
||||||
lastName = lastName,
|
lastName = lastName,
|
||||||
firstName = cell("firstName"),
|
firstName = firstName,
|
||||||
middleName = cell("middleName"),
|
middleName = middleName,
|
||||||
basis = cell("basis"),
|
basis = cell(row, columns, "basis"),
|
||||||
note = cell("note"),
|
note = cell(row, columns, "note"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isStudentHeader(value: String): Boolean {
|
private fun requireColumns(header: List<String>): Map<String, Int> {
|
||||||
val normalized = value.lowercase().replace("\u00a0", " ").trim()
|
val columns = mapColumns(header)
|
||||||
return normalized.startsWith("студен") ||
|
val missing = mutableListOf<String>()
|
||||||
normalized.startsWith("студ.") ||
|
val names =
|
||||||
normalized.startsWith("зачёт") ||
|
mapOf(
|
||||||
normalized.startsWith("зачет")
|
"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> {
|
private fun mapColumns(header: List<String>): Map<String, Int> {
|
||||||
val result = mutableMapOf<String, Int>()
|
val result = mutableMapOf<String, Int>()
|
||||||
header.forEachIndexed { index, raw ->
|
header.forEachIndexed { index, raw ->
|
||||||
val value = raw.lowercase().replace("\u00a0", " ").trim()
|
val value = normalizeHeader(raw)
|
||||||
|
HEADER_RULES
|
||||||
fun putIfAbsent(
|
.firstOrNull { it.matches(value) }
|
||||||
key: String,
|
?.let { result.putIfAbsent(it.key, index) }
|
||||||
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", "примеч")
|
|
||||||
}
|
}
|
||||||
return result
|
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(
|
private fun cell(
|
||||||
filename: String,
|
row: List<String>,
|
||||||
rows: List<List<String>>,
|
columns: Map<String, Int>,
|
||||||
): String {
|
key: String,
|
||||||
val groupRegex = Regex("[А-ЯA-Z][А-ЯA-Za-z]*[- ]?\\d{1,4}[А-ЯA-Za-z]*")
|
): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
|
||||||
for (row in rows.take(12)) {
|
|
||||||
for (cell in row) {
|
private fun parseInt(value: String): Int? = normalizeNumber(value).takeIf { it.isNotBlank() }?.toIntOrNull()
|
||||||
val value = cell.trim()
|
|
||||||
if (value.length in 2..30 && groupRegex.matches(value)) {
|
private fun normalizeNumber(value: String): String = if (value.endsWith(".0")) value.dropLast(2) else value
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return filename.substringBeforeLast('.').trim().ifBlank { "Группа" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -3,6 +3,15 @@ package ru.ysu.lms.web.admin
|
|||||||
// Формы админ-контроллеров: связываются через @ModelAttribute.
|
// Формы админ-контроллеров: связываются через @ModelAttribute.
|
||||||
// Имена полей совпадают с именами полей в шаблонах (то же, что было в @RequestParam).
|
// Имена полей совпадают с именами полей в шаблонах (то же, что было в @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(
|
data class CreateStudentForm(
|
||||||
val studentNo: String = "",
|
val studentNo: String = "",
|
||||||
val lastName: String = "",
|
val lastName: String = "",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes
|
|||||||
import ru.ysu.lms.markdown.MarkdownRenderer
|
import ru.ysu.lms.markdown.MarkdownRenderer
|
||||||
import ru.ysu.lms.web.api.ApiClient
|
import ru.ysu.lms.web.api.ApiClient
|
||||||
import ru.ysu.lms.web.api.ApiException
|
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.DefenseDto
|
||||||
import ru.ysu.lms.web.api.GradeSetPayload
|
import ru.ysu.lms.web.api.GradeSetPayload
|
||||||
import ru.ysu.lms.web.api.GroupDetailDto
|
import ru.ysu.lms.web.api.GroupDetailDto
|
||||||
@@ -100,12 +101,22 @@ class AdminGroupController(
|
|||||||
|
|
||||||
@PostMapping("/groups")
|
@PostMapping("/groups")
|
||||||
fun createGroup(
|
fun createGroup(
|
||||||
@RequestParam name: String,
|
@ModelAttribute form: CreateGroupForm,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String =
|
): String =
|
||||||
try {
|
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.addFlashAttribute("notice", "Группа создана")
|
||||||
"redirect:/admin/groups"
|
"redirect:/admin/groups"
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
@@ -116,21 +127,19 @@ class AdminGroupController(
|
|||||||
@PostMapping("/groups/import")
|
@PostMapping("/groups/import")
|
||||||
fun importGroup(
|
fun importGroup(
|
||||||
@RequestParam("file") file: MultipartFile,
|
@RequestParam("file") file: MultipartFile,
|
||||||
@RequestParam(name = "name", required = false) name: String?,
|
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String =
|
): String =
|
||||||
try {
|
try {
|
||||||
if (file.isEmpty) throw ApiException(400, "Выберите файл со списком группы")
|
if (file.isEmpty) throw ApiException(400, "Выберите файл со списком группы")
|
||||||
val result =
|
val result = api.importGroup(token(session), file.originalFilename ?: "group.xlsx", file.bytes)
|
||||||
api.importGroup(
|
|
||||||
token(session),
|
|
||||||
name,
|
|
||||||
file.originalFilename ?: "group.xlsx",
|
|
||||||
file.bytes,
|
|
||||||
)
|
|
||||||
redirect.addFlashAttribute("importResult", result)
|
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) {
|
} catch (ex: ApiException) {
|
||||||
redirect.addFlashAttribute("error", ex.message)
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
"redirect:/admin/groups"
|
"redirect:/admin/groups"
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class AdminStudentController(
|
|||||||
): String =
|
): String =
|
||||||
try {
|
try {
|
||||||
api.unenrollStudent(token(session), groupId, studentId)
|
api.unenrollStudent(token(session), groupId, studentId)
|
||||||
redirect.addFlashAttribute("notice", "Студент отчислён из группы (аккаунт и данные сохранены)")
|
redirect.addFlashAttribute("notice", "Студент отчислен: аккаунт и данные сохранены, вход закрыт")
|
||||||
"redirect:/admin/groups/$groupId"
|
"redirect:/admin/groups/$groupId"
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
redirect.addFlashAttribute("error", ex.message)
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ fun ApiClient.listGroups(token: String): List<GroupDto> {
|
|||||||
|
|
||||||
fun ApiClient.createGroup(
|
fun ApiClient.createGroup(
|
||||||
token: String,
|
token: String,
|
||||||
name: String,
|
payload: CreateGroupPayload,
|
||||||
): GroupDto =
|
): GroupDto =
|
||||||
call {
|
call {
|
||||||
client
|
client
|
||||||
@@ -30,14 +30,13 @@ fun ApiClient.createGroup(
|
|||||||
.uri("/api/v1/groups")
|
.uri("/api/v1/groups")
|
||||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.body(CreateGroupPayload(name))
|
.body(payload)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(GroupDto::class.java)
|
.body(GroupDto::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun ApiClient.importGroup(
|
fun ApiClient.importGroup(
|
||||||
token: String,
|
token: String,
|
||||||
name: String?,
|
|
||||||
filename: String,
|
filename: String,
|
||||||
bytes: ByteArray,
|
bytes: ByteArray,
|
||||||
): ImportResultDto {
|
): ImportResultDto {
|
||||||
@@ -48,9 +47,6 @@ fun ApiClient.importGroup(
|
|||||||
override fun getFilename(): String = filename
|
override fun getFilename(): String = filename
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (!name.isNullOrBlank()) {
|
|
||||||
parts.add("name", name)
|
|
||||||
}
|
|
||||||
return call {
|
return call {
|
||||||
client
|
client
|
||||||
.post()
|
.post()
|
||||||
|
|||||||
@@ -39,12 +39,23 @@ data class ChangePasswordPayload(
|
|||||||
data class GroupDto(
|
data class GroupDto(
|
||||||
val id: String = "",
|
val id: String = "",
|
||||||
val name: 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 studentCount: Long = 0,
|
||||||
val taskDurationMin: Int = 30,
|
val taskDurationMin: Int = 30,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class CreateGroupPayload(
|
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)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
@@ -57,10 +68,18 @@ data class CredentialDto(
|
|||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class ImportResultDto(
|
data class ImportResultDto(
|
||||||
val groupId: String = "",
|
val applied: Boolean = true,
|
||||||
val groupName: String = "",
|
val groupId: String? = null,
|
||||||
|
val groupName: String? = null,
|
||||||
val added: List<CredentialDto> = emptyList(),
|
val added: List<CredentialDto> = emptyList(),
|
||||||
val updated: List<String> = 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)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
@@ -69,6 +88,8 @@ data class StudentRowDto(
|
|||||||
val studentNo: String = "",
|
val studentNo: String = "",
|
||||||
val fullName: String = "",
|
val fullName: String = "",
|
||||||
val basis: String = "",
|
val basis: String = "",
|
||||||
|
val status: String = "ACTIVE",
|
||||||
|
val enrollmentYear: Short? = null,
|
||||||
val mustChangePassword: Boolean = false,
|
val mustChangePassword: Boolean = false,
|
||||||
val tempPassword: String? = null,
|
val tempPassword: String? = null,
|
||||||
)
|
)
|
||||||
@@ -77,6 +98,12 @@ data class StudentRowDto(
|
|||||||
data class GroupDetailDto(
|
data class GroupDetailDto(
|
||||||
val id: String = "",
|
val id: String = "",
|
||||||
val name: 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 taskDurationMin: Int = 30,
|
||||||
val students: List<StudentRowDto> = emptyList(),
|
val students: List<StudentRowDto> = emptyList(),
|
||||||
)
|
)
|
||||||
@@ -97,6 +124,8 @@ data class AdminUserDto(
|
|||||||
val studentNo: String = "",
|
val studentNo: String = "",
|
||||||
val fullName: String = "",
|
val fullName: String = "",
|
||||||
val basis: String = "",
|
val basis: String = "",
|
||||||
|
val status: String = "ACTIVE",
|
||||||
|
val enrollmentYear: Short? = null,
|
||||||
val groupId: String? = null,
|
val groupId: String? = null,
|
||||||
val groupName: String? = null,
|
val groupName: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
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>
|
||||||
|
<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>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">
|
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">
|
||||||
@@ -118,6 +123,8 @@
|
|||||||
aria-label="Статус пароля"></span>
|
aria-label="Статус пароля"></span>
|
||||||
<div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div>
|
<div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div>
|
||||||
</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>
|
||||||
<td th:each="cell, iterStat : ${row.cells}" class="text-center cell-lab"
|
<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 : 'без варианта'}">
|
th:attr="data-lab=${cell.labId},data-sort=${cell.variantNumber},data-filter-label=${cell.variantNumber != null ? 'вариант ' + cell.variantNumber : 'без варианта'}">
|
||||||
@@ -184,7 +191,7 @@
|
|||||||
title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить</button>
|
title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить</button>
|
||||||
</form>
|
</form>
|
||||||
<form th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}"
|
<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}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<button class="btn btn-sm btn-danger" type="submit">Отчислить</button>
|
<button class="btn btn-sm btn-danger" type="submit">Отчислить</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -13,16 +13,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="card card-flush">
|
||||||
<table id="groups-table" class="tablekit">
|
<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>
|
<tbody>
|
||||||
<tr th:each="group : ${groups}">
|
<tr th:each="group : ${groups}">
|
||||||
<td><a th:href="@{/admin/groups/{id}(id=${group.id})}" th:text="${group.name}">—</a></td>
|
<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 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>
|
<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>
|
||||||
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,26 +63,44 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="dlg-body">
|
<div class="dlg-body">
|
||||||
<form class="form-stack" th:action="@{/admin/groups}" method="post">
|
<form class="form-stack" th:action="@{/admin/groups}" method="post">
|
||||||
<div><label class="label" for="name">Название</label>
|
<div class="row g-3">
|
||||||
<input class="input" id="name" name="name" placeholder="ПИ-11БО" required autofocus></div>
|
<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>
|
<button class="btn btn-primary" type="submit">Создать</button>
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<dialog id="modal-import-group" class="dlg">
|
<dialog id="modal-import-group" class="dlg dlg-lg">
|
||||||
<div class="dlg-header">
|
<div class="dlg-header">
|
||||||
<h2 class="dlg-title">Импорт студентов</h2>
|
<h2 class="dlg-title">Импорт студентов</h2>
|
||||||
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
|
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="dlg-body">
|
<div class="dlg-body">
|
||||||
<form class="form-stack" th:action="@{/admin/groups/import}" method="post" enctype="multipart/form-data">
|
<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>
|
<div><label class="label" for="file">Файл со списком</label>
|
||||||
<input class="input" id="file" name="file" type="file" accept=".xls,.xlsx,.csv" required autofocus></div>
|
<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>
|
<button class="btn btn-primary" type="submit">Импортировать</button>
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<th class="th-sortable is-asc" title="ФИО — по возрастанию">ФИО</th>
|
<th class="th-sortable is-asc" title="ФИО — по возрастанию">ФИО</th>
|
||||||
<th>Логин</th>
|
<th>Логин</th>
|
||||||
<th>Группа</th>
|
<th>Группа</th>
|
||||||
|
<th>Статус</th>
|
||||||
<th class="text-right" data-nosort>Действия</th>
|
<th class="text-right" data-nosort>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -27,6 +28,10 @@
|
|||||||
<td class="font-medium" th:text="${u.fullName}">—</td>
|
<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.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><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">
|
<td class="text-right">
|
||||||
<form class="inline-form" th:action="@{/admin/users/{id}/delete(id=${u.id})}" method="post" data-confirm="Удалить пользователя безвозвратно? Будут удалены аккаунт, группа, оценки с комментариями, сдачи с файлами и задания защит.">
|
<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}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
@@ -35,7 +40,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user