From 07a223b759bf7cb4f2a8d38ac30e74dd6f5b9e5e Mon Sep 17 00:00:00 2001
From: Vladislav Kopnin
Date: Mon, 26 Oct 2026 15:47:49 +0300
Subject: [PATCH] =?UTF-8?q?feat(identity):=20=D0=B3=D1=80=D1=83=D0=BF?=
=?UTF-8?q?=D0=BF=D1=8B-=D0=BA=D0=BE=D0=B3=D0=BE=D1=80=D1=82=D1=8B,=20?=
=?UTF-8?q?=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20=D0=B8=20=D0=B8?=
=?UTF-8?q?=D0=BC=D0=BF=D0=BE=D1=80=D1=82=20v2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 10 +-
deploy/IMPORT.md | 23 +-
docs/03-domain-model.md | 77 ++++-
docs/11-stage1-report.md | 95 ++++++
.../ru/ysu/lms/security/JwtPrincipal.kt | 1 +
.../ru/ysu/lms/identity/auth/AuthService.kt | 14 +
.../ru/ysu/lms/identity/domain/Account.kt | 5 +
.../ysu/lms/identity/domain/AccountStatus.kt | 6 +
.../ru/ysu/lms/identity/domain/GroupEntity.kt | 15 +-
.../ru/ysu/lms/identity/domain/GroupStatus.kt | 7 +
.../lms/identity/domain/MembershipAction.kt | 14 +
.../lms/identity/domain/MembershipHistory.kt | 35 ++
.../ru/ysu/lms/identity/domain/Student.kt | 7 +
.../ysu/lms/identity/domain/StudentStatus.kt | 9 +
.../ysu/lms/identity/repo/GroupRepository.kt | 10 +
.../repo/MembershipHistoryRepository.kt | 9 +
.../ru/ysu/lms/identity/roster/GroupCohort.kt | 40 +++
.../identity/roster/GroupCohortResolver.kt | 76 +++++
.../lms/identity/roster/GroupController.kt | 14 +-
.../lms/identity/roster/GroupImportService.kt | 183 ++++++++++
.../ysu/lms/identity/roster/GroupService.kt | 240 ++++++-------
.../ru/ysu/lms/identity/roster/RosterDtos.kt | 39 ++-
.../identity/roster/StudentImportParser.kt | 316 +++++++++++++-----
.../identity/roster/GroupNameParserTest.kt | 48 +++
.../roster/GroupServiceIntegrationTest.kt | 171 ++++++++++
.../roster/StudentImportParserTest.kt | 79 +++++
.../kotlin/ru/ysu/lms/web/admin/AdminForms.kt | 9 +
.../ysu/lms/web/admin/AdminGroupController.kt | 31 +-
.../lms/web/admin/AdminStudentController.kt | 2 +-
.../ru/ysu/lms/web/api/ApiClientIdentity.kt | 8 +-
.../main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt | 35 +-
.../main/resources/templates/admin/group.html | 9 +-
.../resources/templates/admin/groups.html | 63 +++-
.../main/resources/templates/admin/users.html | 7 +-
34 files changed, 1459 insertions(+), 248 deletions(-)
create mode 100644 docs/11-stage1-report.md
create mode 100644 services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/AccountStatus.kt
create mode 100644 services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/GroupStatus.kt
create mode 100644 services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/MembershipAction.kt
create mode 100644 services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/MembershipHistory.kt
create mode 100644 services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/StudentStatus.kt
create mode 100644 services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/MembershipHistoryRepository.kt
create mode 100644 services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupCohort.kt
create mode 100644 services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupCohortResolver.kt
create mode 100644 services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupImportService.kt
create mode 100644 services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/GroupNameParserTest.kt
create mode 100644 services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/GroupServiceIntegrationTest.kt
create mode 100644 services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/StudentImportParserTest.kt
diff --git a/README.md b/README.md
index 03858fc..9225c24 100644
--- a/README.md
+++ b/README.md
@@ -98,11 +98,13 @@ cp .env.prod.example .env.prod # заполнить LMS_DB_PASSWORD
администратора (`/setup`); далее — вход по логину и паролю.
- **Доступ**: JWT (RSA, ключи identity генерирует при первом старте и хранит в БД),
- проверка токена на gateway, роли ADMIN/STUDENT, CSRF на всех POST-формах,
+ проверка токена на gateway, роли ADMIN/TEACHER/STUDENT, CSRF на всех POST-формах,
session fixation-защита; вход студента с обязательной сменой пароля.
-- **Группы и студенты**: создание групп, импорт `.xls/.xlsx/.csv`, ручное
- добавление студента (генерация пароля) и «Добавить существующего» (перевод из
- другой группы), «Отчислить из группы», выгрузка логинов XLSX/CSV, сброс пароля.
+- **Группы и студенты**: группы-когорты (поток, курс, номер, степень, год набора),
+ импорт `.xls/.xlsx/.csv` по шаблону `поток/курс/номер/степень/год набора/ФИО/№/база`,
+ ручное добавление студента (генерация пароля) и «Добавить существующего»
+ (перевод из другой группы), «Отчислить из группы», выгрузка логинов XLSX/CSV,
+ сброс пароля, история членства.
- **Лабораторные**: импорт ZIP (`lab.yaml` + `README.md` + `variants/*.md` +
`assets/`); создание через UI с произвольным числом уровней; единая форма
настроек с одной кнопкой «Сохранить всё»; статусы draft/published
diff --git a/deploy/IMPORT.md b/deploy/IMPORT.md
index a8269b6..819b8b8 100644
--- a/deploy/IMPORT.md
+++ b/deploy/IMPORT.md
@@ -11,16 +11,27 @@
## Шаг 2. Группы и студенты
-**Группы → Импорт** — загрузите `.xls`/`.xlsx`/`.csv` со списком группы
-(столбцы: «Студенческий номер», «Фамилия Имя Отчество», «Основа обучения»).
-Название группы берётся из файла или указывается при импорте. Логином студента
-становится студенческий номер, пароль генерируется (временный, с обязательной
-сменой при первом входе).
+**Группы → Импорт** — загрузите `.xls`/`.xlsx`/`.csv` со списком группы.
+Обязательные столбцы:
+
+```text
+поток | курс | номер | степень | год набора | ФИО | № | база
+```
+
+Все строки файла должны относиться к одной группе. Группа создаётся по полям
+(имя собирается автоматически, например `ПИ-11БО`). Логином студента становится
+номер (`№`), пароль генерируется (временный, с обязательной сменой при первом
+входе). Ключ слияния — `student_no`: повторный импорт обновляет студента, а не
+дублирует.
+
+Импорт атомарный: при ошибке в любой строке данные не меняются, а в интерфейсе
+показывается отчёт с номерами строк.
Дальше по каждому студенту при необходимости:
* **«Сбросить пароль»** — выдать новый временный пароль;
* **«Добавить существующего»** — перевести студента из другой группы;
-* **«Отчислить из группы»** — убрать из группы (аккаунт и данные сохраняются).
+* **«Отчислить из группы»** — убрать из группы, студент получает статус
+ «отчислен»; аккаунт, сдачи и оценки сохраняются.
Выгрузить логины: **«Логины XLSX» / «CSV»**.
diff --git a/docs/03-domain-model.md b/docs/03-domain-model.md
index 7f88e7e..47d1a5d 100644
--- a/docs/03-domain-model.md
+++ b/docs/03-domain-model.md
@@ -12,6 +12,8 @@ classDiagram
+String login
+String passwordHash
+LmsRole role
+ +AccountStatus status
+ +Instant archivedAt
+Boolean mustChangePassword
+String tempPassword
+Instant createdAt
@@ -25,11 +27,19 @@ classDiagram
+String middleName
+String basis
+String note
+ +StudentStatus status
+ +Short enrollmentYear
+String fullName
}
class GroupEntity {
+UUID id
+String name
+ +String stream
+ +Short course
+ +Short number
+ +String degree
+ +Short enrollmentYear
+ +GroupStatus status
+Int taskDurationMin
+Instant createdAt
}
@@ -37,6 +47,17 @@ classDiagram
+UUID id
+Instant enrolledAt
}
+ class MembershipHistory {
+ +UUID id
+ +UUID studentId
+ +UUID fromGroupId
+ +UUID toGroupId
+ +MembershipAction action
+ +String reason
+ +UUID actorAccountId
+ +UUID promotionRunId
+ +Instant at
+ }
class Setting {
+String key
+String value
@@ -44,12 +65,15 @@ classDiagram
class LmsRole {
<>
ADMIN
+ TEACHER
STUDENT
}
Account "1" --> "1" Student : @MapsId account_id
GroupEntity "1" --> "*" Enrollment : group_id
Student "1" --> "*" Enrollment : student_id
+ Student "1" --> "*" MembershipHistory : student_id
+ GroupEntity "1" --> "*" MembershipHistory : from/to_group_id
Account --> LmsRole : role
```
@@ -68,20 +92,28 @@ classDiagram
class GroupService {
+list() List~GroupSummary~
+create(CreateGroupRequest) GroupSummary
- +createStudent(UUID, CreateStudentRequest) Credential
+ +createStudent(UUID, CreateStudentRequest, UUID) Credential
+candidates(UUID) List~StudentCandidate~
- +enroll(UUID, UUID) GroupDetail
- +unenroll(UUID, UUID) GroupDetail
- +import(String, String, ByteArray) ImportResult
+ +enroll(UUID, UUID, UUID) GroupDetail
+ +unenroll(UUID, UUID, UUID) GroupDetail
+groupOfStudent(UUID) GroupSummary
+detail(UUID) GroupDetail
+resetPassword(UUID) ResetPasswordResponse
+credentials(UUID, String) ExportFile
}
+ class GroupImportService {
+ +import(String, ByteArray, UUID) ImportResult
+ }
+ class GroupCohortResolver {
+ +resolve(CreateGroupRequest) GroupCohort
+ +validate(GroupCohort) GroupCohort
+ +defaultEnrollmentYear(Short) Short
+ }
class AccountRepository
class GroupRepository
class EnrollmentRepository
class StudentRepository
+ class MembershipHistoryRepository
class SettingRepository
class StudentImportParser {
+parse(String, ByteArray) ParsedGroup
@@ -96,6 +128,7 @@ classDiagram
AuthController --> AuthService
GroupController --> GroupService
+ GroupController --> GroupImportService
StudentController --> GroupService
AuthService --> AccountRepository
AuthService --> StudentRepository
@@ -103,7 +136,14 @@ classDiagram
GroupService --> EnrollmentRepository
GroupService --> StudentRepository
GroupService --> AccountRepository
- GroupService --> StudentImportParser
+ GroupService --> MembershipHistoryRepository
+ GroupService --> GroupCohortResolver
+ GroupImportService --> GroupRepository
+ GroupImportService --> EnrollmentRepository
+ GroupImportService --> StudentRepository
+ GroupImportService --> AccountRepository
+ GroupImportService --> MembershipHistoryRepository
+ GroupImportService --> StudentImportParser
GroupService --> CredentialsExporter
GroupService --> Passwords
```
@@ -516,6 +556,8 @@ erDiagram
varchar login UK
varchar password_hash
varchar role
+ varchar status
+ timestamptz archived_at
boolean must_change_password
varchar temp_password
timestamptz created_at
@@ -529,10 +571,18 @@ erDiagram
varchar middle_name
varchar basis
text note
+ varchar status
+ smallint enrollment_year
}
GROUPS {
uuid id PK
- varchar name UK
+ varchar name
+ varchar stream
+ smallint course
+ smallint number
+ varchar degree
+ smallint enrollment_year
+ varchar status
int task_duration_min
timestamptz created_at
}
@@ -542,14 +592,27 @@ erDiagram
uuid student_id FK
timestamptz enrolled_at
}
+ MEMBERSHIP_HISTORY {
+ uuid id PK
+ uuid student_id FK
+ uuid from_group_id FK
+ uuid to_group_id FK
+ varchar action
+ text reason
+ uuid actor_account_id
+ uuid promotion_run_id
+ timestamptz at
+ }
SETTINGS {
varchar key PK
varchar value
}
- ACCOUNTS ||--|| STUDENTS : "account_id"
+ ACCOUNTS ||--o| STUDENTS : "account_id"
GROUPS ||--o{ ENROLLMENTS : "group_id"
STUDENTS ||--o{ ENROLLMENTS : "student_id"
+ STUDENTS ||--o{ MEMBERSHIP_HISTORY : "student_id"
+ GROUPS ||--o{ MEMBERSHIP_HISTORY : "from/to_group_id"
SEMESTERS {
uuid id PK
diff --git a/docs/11-stage1-report.md b/docs/11-stage1-report.md
new file mode 100644
index 0000000..90bbeac
--- /dev/null
+++ b/docs/11-stage1-report.md
@@ -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 курсов.
diff --git a/libs/security-jwt/src/main/kotlin/ru/ysu/lms/security/JwtPrincipal.kt b/libs/security-jwt/src/main/kotlin/ru/ysu/lms/security/JwtPrincipal.kt
index df4dc41..82fc9a3 100644
--- a/libs/security-jwt/src/main/kotlin/ru/ysu/lms/security/JwtPrincipal.kt
+++ b/libs/security-jwt/src/main/kotlin/ru/ysu/lms/security/JwtPrincipal.kt
@@ -2,6 +2,7 @@ package ru.ysu.lms.security
enum class LmsRole {
ADMIN,
+ TEACHER,
STUDENT,
;
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthService.kt
index 24d01f6..5ef8970 100644
--- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthService.kt
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthService.kt
@@ -13,6 +13,8 @@ import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.domain.Account
+import ru.ysu.lms.identity.domain.AccountStatus
+import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.AccountRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.JwtProperties
@@ -61,10 +63,22 @@ class AuthService(
backoff.recordFailure(login)
throw UnauthorizedException("Неверный логин или пароль")
}
+ ensureLoginAllowed(account)
backoff.reset(login)
return toResponse(account, displayName(account))
}
+ private fun ensureLoginAllowed(account: Account) {
+ if (account.status != AccountStatus.ACTIVE) {
+ throw UnauthorizedException("Аккаунт архивирован")
+ }
+ if (account.role != LmsRole.STUDENT) return
+ val status = students.findById(account.id).map { it.status }.orElse(null)
+ if (status in setOf(StudentStatus.EXPELLED, StudentStatus.GRADUATED, StudentStatus.ARCHIVED)) {
+ throw UnauthorizedException("Доступ к системе закрыт")
+ }
+ }
+
@Transactional(readOnly = true)
fun me(accountId: UUID): MeResponse {
val account =
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/Account.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/Account.kt
index e147528..17b86c2 100644
--- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/Account.kt
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/Account.kt
@@ -27,6 +27,11 @@ class Account(
var mustChangePassword: Boolean = true,
@Column(name = "temp_password", length = 64)
var tempPassword: String? = null,
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false, length = 16)
+ var status: AccountStatus = AccountStatus.ACTIVE,
+ @Column(name = "archived_at")
+ var archivedAt: Instant? = null,
@Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(),
@Column(name = "updated_at", nullable = false)
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/AccountStatus.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/AccountStatus.kt
new file mode 100644
index 0000000..e960f2c
--- /dev/null
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/AccountStatus.kt
@@ -0,0 +1,6 @@
+package ru.ysu.lms.identity.domain
+
+enum class AccountStatus {
+ ACTIVE,
+ ARCHIVED,
+}
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/GroupEntity.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/GroupEntity.kt
index f7fc756..cc722fa 100644
--- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/GroupEntity.kt
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/GroupEntity.kt
@@ -2,6 +2,8 @@ package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
+import jakarta.persistence.EnumType
+import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
@@ -13,8 +15,19 @@ import java.util.UUID
class GroupEntity(
@Id
var id: UUID = Ids.v7(),
- @Column(nullable = false, unique = true, length = 100)
+ @Column(nullable = false, length = 100)
var name: String = "",
+ @Column(length = 50)
+ var stream: String? = null,
+ var course: Short? = null,
+ var number: Short? = null,
+ @Column(length = 2)
+ var degree: String? = null,
+ @Column(name = "enrollment_year")
+ var enrollmentYear: Short? = null,
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false, length = 16)
+ var status: GroupStatus = GroupStatus.ACTIVE,
@Column(name = "task_duration_min", nullable = false)
var taskDurationMin: Int = 30,
@Column(name = "created_at", nullable = false)
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/GroupStatus.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/GroupStatus.kt
new file mode 100644
index 0000000..bb6f8ed
--- /dev/null
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/GroupStatus.kt
@@ -0,0 +1,7 @@
+package ru.ysu.lms.identity.domain
+
+enum class GroupStatus {
+ ACTIVE,
+ GRADUATED,
+ ARCHIVED,
+}
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/MembershipAction.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/MembershipAction.kt
new file mode 100644
index 0000000..297e4f6
--- /dev/null
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/MembershipAction.kt
@@ -0,0 +1,14 @@
+package ru.ysu.lms.identity.domain
+
+enum class MembershipAction {
+ ENROLL,
+ TRANSFER,
+ ACADEMIC,
+ RETURN,
+ GRADUATE,
+ EXPELL,
+ PROMOTE,
+ IMPORT,
+ ARCHIVE,
+ RESTORE,
+}
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/MembershipHistory.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/MembershipHistory.kt
new file mode 100644
index 0000000..d13b021
--- /dev/null
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/MembershipHistory.kt
@@ -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(),
+)
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/Student.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/Student.kt
index 4a3d218..233189b 100644
--- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/Student.kt
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/Student.kt
@@ -2,6 +2,8 @@ package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
+import jakarta.persistence.EnumType
+import jakarta.persistence.Enumerated
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
@@ -28,6 +30,11 @@ class Student(
var basis: String = "",
@Column(name = "note", nullable = false)
var note: String = "",
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false, length = 16)
+ var status: StudentStatus = StudentStatus.ACTIVE,
+ @Column(name = "enrollment_year")
+ var enrollmentYear: Short? = null,
@OneToOne(fetch = FetchType.LAZY)
@MapsId
@JoinColumn(name = "account_id")
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/StudentStatus.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/StudentStatus.kt
new file mode 100644
index 0000000..7687f20
--- /dev/null
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/StudentStatus.kt
@@ -0,0 +1,9 @@
+package ru.ysu.lms.identity.domain
+
+enum class StudentStatus {
+ ACTIVE,
+ ACADEMIC_LEAVE,
+ EXPELLED,
+ GRADUATED,
+ ARCHIVED,
+}
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/GroupRepository.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/GroupRepository.kt
index 27ff21b..974be05 100644
--- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/GroupRepository.kt
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/GroupRepository.kt
@@ -9,5 +9,15 @@ interface GroupRepository : JpaRepository {
fun existsByName(name: String): Boolean
+ fun findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
+ stream: String,
+ degree: String,
+ number: Short,
+ course: Short,
+ enrollmentYear: Short,
+ ): GroupEntity?
+
fun findAllByOrderByNameAsc(): List
+
+ fun findAllByOrderByEnrollmentYearDescNameAsc(): List
}
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/MembershipHistoryRepository.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/MembershipHistoryRepository.kt
new file mode 100644
index 0000000..7449bbc
--- /dev/null
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/MembershipHistoryRepository.kt
@@ -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 {
+ fun findByStudentIdOrderByAtDesc(studentId: UUID): List
+}
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupCohort.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupCohort.kt
new file mode 100644
index 0000000..dee5633
--- /dev/null
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupCohort.kt
@@ -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
+ }
+}
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupCohortResolver.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupCohortResolver.kt
new file mode 100644
index 0000000..3b49bf7
--- /dev/null
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupCohortResolver.kt
@@ -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()
+ }
+}
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt
index 31f9518..9f537a0 100644
--- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt
@@ -26,6 +26,7 @@ import java.util.UUID
@PreAuthorize("hasRole('ADMIN')")
class GroupController(
private val groupService: GroupService,
+ private val importService: GroupImportService,
) {
@GetMapping
fun list(): List = groupService.list()
@@ -39,17 +40,18 @@ class GroupController(
fun createStudent(
@PathVariable id: UUID,
@Valid @RequestBody body: CreateStudentRequest,
- ): Credential = groupService.createStudent(id, body)
+ @AuthenticationPrincipal jwt: Jwt,
+ ): Credential = groupService.createStudent(id, body, UUID.fromString(jwt.subject))
@PostMapping("/import")
fun import(
@RequestParam("file") file: MultipartFile,
- @RequestParam(name = "name", required = false) name: String?,
+ @AuthenticationPrincipal jwt: Jwt,
): ImportResult {
if (file.isEmpty) {
throw BadRequestException("Выберите файл со списком группы")
}
- return groupService.import(name, file.originalFilename ?: "group.xlsx", file.bytes)
+ return importService.import(file.originalFilename ?: "group.xlsx", file.bytes, UUID.fromString(jwt.subject))
}
/** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */
@@ -63,14 +65,16 @@ class GroupController(
fun enroll(
@PathVariable id: UUID,
@PathVariable studentId: UUID,
- ): GroupDetail = groupService.enroll(id, studentId)
+ @AuthenticationPrincipal jwt: Jwt,
+ ): GroupDetail = groupService.enroll(id, studentId, UUID.fromString(jwt.subject))
/** Отчислить студента из группы. */
@DeleteMapping("/{id}/students/{studentId}")
fun unenroll(
@PathVariable id: UUID,
@PathVariable studentId: UUID,
- ): GroupDetail = groupService.unenroll(id, studentId)
+ @AuthenticationPrincipal jwt: Jwt,
+ ): GroupDetail = groupService.unenroll(id, studentId, UUID.fromString(jwt.subject))
@GetMapping("/me")
@PreAuthorize("isAuthenticated()")
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupImportService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupImportService.kt
new file mode 100644
index 0000000..8cdb2ff
--- /dev/null
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupImportService.kt
@@ -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()
+ val updated = mutableListOf()
+
+ 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,
+ updated: MutableList,
+ ): 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,
+ ),
+ )
+ }
+}
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt
index ff81852..de40136 100644
--- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt
@@ -10,37 +10,43 @@ import ru.ysu.lms.identity.auth.Passwords
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.Enrollment
import ru.ysu.lms.identity.domain.GroupEntity
+import ru.ysu.lms.identity.domain.GroupStatus
+import ru.ysu.lms.identity.domain.MembershipAction
+import ru.ysu.lms.identity.domain.MembershipHistory
import ru.ysu.lms.identity.domain.Student
+import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.AccountRepository
import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.GroupRepository
+import ru.ysu.lms.identity.repo.MembershipHistoryRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.LmsRole
import java.util.UUID
+@Suppress("LongParameterList")
@Service
class GroupService(
private val groups: GroupRepository,
private val enrollments: EnrollmentRepository,
private val students: StudentRepository,
private val accounts: AccountRepository,
+ private val history: MembershipHistoryRepository,
private val passwordEncoder: PasswordEncoder,
- private val parser: StudentImportParser,
+ private val cohortResolver: GroupCohortResolver,
) {
@Transactional(readOnly = true)
- fun list(): List = groups.findAllByOrderByNameAsc().map { summary(it) }
+ fun list(): List = groups.findAllByOrderByEnrollmentYearDescNameAsc().map { summary(it) }
@Transactional
fun createStudent(
groupId: UUID,
request: CreateStudentRequest,
+ actorAccountId: UUID? = null,
): Credential {
- val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
+ val group = activeGroup(groupId)
val studentNo = request.studentNo.trim()
if (studentNo.isBlank()) throw BadRequestException("Студенческий номер не может быть пустым")
- if (students.existsByStudentNo(studentNo)) {
- throw ConflictException("Студент с номером $studentNo уже существует")
- }
+ ensureStudentNumberAvailable(studentNo)
val password = Passwords.generate()
val account =
accounts.save(
@@ -52,7 +58,6 @@ class GroupService(
tempPassword = password,
),
)
- // Student.id — это account_id через @MapsId: ассоциацию задаём до persist.
val student =
students.save(
Student(
@@ -62,10 +67,19 @@ class GroupService(
middleName = request.middleName.trim(),
basis = request.basis.trim(),
note = request.note.trim(),
+ status = StudentStatus.ACTIVE,
+ enrollmentYear = group.enrollmentYear,
account = account,
),
)
enrollments.save(Enrollment(group = group, student = student))
+ recordHistory(
+ studentNo = student.accountId!!,
+ fromGroupId = null,
+ toGroupId = group.id,
+ action = MembershipAction.ENROLL,
+ actorAccountId = actorAccountId,
+ )
return Credential(
id = account.id.toString(),
studentNo = studentNo,
@@ -76,11 +90,31 @@ class GroupService(
@Transactional
fun create(request: CreateGroupRequest): GroupSummary {
- val name = request.name.trim()
- if (groups.existsByName(name)) {
- throw ConflictException("Группа «$name» уже существует")
+ val cohort = cohortResolver.resolve(request)
+ val existing =
+ groups.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
+ cohort.stream,
+ cohort.degree,
+ cohort.number,
+ cohort.course,
+ cohort.enrollmentYear,
+ )
+ if (existing != null) {
+ throw ConflictException("Группа «${cohort.name}» с таким годом набора уже существует")
}
- return summary(groups.save(GroupEntity(name = name)))
+ val group =
+ groups.save(
+ GroupEntity(
+ name = cohort.name,
+ stream = cohort.stream,
+ course = cohort.course,
+ number = cohort.number,
+ degree = cohort.degree,
+ enrollmentYear = cohort.enrollmentYear,
+ status = GroupStatus.ACTIVE,
+ ),
+ )
+ return summary(group)
}
/** Студенты, которых ещё нет в этой группе (включая состоящих в других). */
@@ -116,129 +150,52 @@ class GroupService(
fun enroll(
groupId: UUID,
studentId: UUID,
+ actorAccountId: UUID? = null,
): GroupDetail {
- val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
+ val group = activeGroup(groupId)
val student = students.findById(studentId).orElseThrow { NotFoundException("Студент не найден") }
val existing = enrollments.findByStudentAccountId(studentId)
- if (existing != null) {
- if (existing.group?.id == group.id) {
- throw ConflictException("Студент уже в этой группе")
- }
- // unique-констрейт на student_id: переводим перепривязкой, без delete+insert
+ val fromGroupId = existing?.group?.id
+ if (fromGroupId == group.id) {
+ throw ConflictException("Студент уже в этой группе")
+ }
+ if (existing == null) {
+ enrollments.save(Enrollment(group = group, student = student))
+ } else {
existing.group = group
enrollments.save(existing)
- } else {
- enrollments.save(Enrollment(group = group, student = student))
}
+ student.status = StudentStatus.ACTIVE
+ student.enrollmentYear = group.enrollmentYear
+ students.save(student)
+ recordHistory(studentId, fromGroupId, group.id, MembershipAction.TRANSFER, actorAccountId)
return detail(groupId)
}
- /** Отчислить из группы (студент и его аккаунт сохраняются). */
+ /** Убрать студента из группы; аккаунт и данные сохраняются. */
@Transactional
fun unenroll(
groupId: UUID,
studentId: UUID,
+ actorAccountId: UUID? = null,
): GroupDetail {
val enrollment =
enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
?: throw NotFoundException("Студент не входит в эту группу")
enrollments.delete(enrollment)
+ val student = students.findById(studentId).orElse(null)
+ if (student != null) {
+ student.status = StudentStatus.EXPELLED
+ students.save(student)
+ }
+ recordHistory(studentId, groupId, null, MembershipAction.EXPELL, actorAccountId)
return detail(groupId)
}
- @Transactional
- fun import(
- requestedName: String?,
- filename: String,
- bytes: ByteArray,
- ): ImportResult {
- val parsed = parser.parse(filename, bytes)
- val groupName = requestedName?.trim().takeUnless { it.isNullOrBlank() } ?: parsed.groupName
- val group = groups.findByName(groupName) ?: groups.save(GroupEntity(name = groupName))
- val added = mutableListOf()
- val updated = mutableListOf()
-
- 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,
- updated: MutableList,
- ): Student {
- val existing = students.findByStudentNo(parsed.studentNo)
- if (existing != null) {
- existing.lastName = parsed.lastName
- existing.firstName = parsed.firstName
- existing.middleName = parsed.middleName
- existing.basis = parsed.basis
- existing.note = parsed.note
- students.save(existing)
- updated.add(parsed.studentNo)
- return existing
- }
- val password = Passwords.generate()
- val account =
- accounts.save(
- Account(
- login = parsed.studentNo,
- passwordHash = passwordEncoder.encode(password) ?: "",
- role = LmsRole.STUDENT,
- mustChangePassword = true,
- tempPassword = password,
- ),
- )
- val created =
- Student(
- studentNo = parsed.studentNo,
- lastName = parsed.lastName,
- firstName = parsed.firstName,
- middleName = parsed.middleName,
- basis = parsed.basis,
- note = parsed.note,
- )
- created.account = account
- students.save(created)
- added.add(
- Credential(
- id = account.id.toString(),
- studentNo = parsed.studentNo,
- fullName = parsed.fullName,
- password = password,
- ),
- )
- return created
- }
-
- /** Зачисляет студента в группу (или переводит из другой). */
- private fun enrollIntoGroup(
- group: GroupEntity,
- student: Student,
- ) {
- val accountId = student.accountId!!
- val enrollment = enrollments.findByStudentAccountId(accountId)
- if (enrollment == null) {
- enrollments.save(Enrollment(group = group, student = student))
- } else {
- enrollment.group = group
- enrollments.save(enrollment)
- }
- }
-
@Transactional(readOnly = true)
fun groupOfStudent(accountId: UUID): GroupSummary? {
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null
- return GroupSummary(
- id = group.id.toString(),
- name = group.name,
- studentCount = enrollments.findAllByGroupId(group.id).size.toLong(),
- taskDurationMin = group.taskDurationMin,
- )
+ return summary(group)
}
@Transactional(readOnly = true)
@@ -255,11 +212,24 @@ class GroupService(
studentNo = student.studentNo,
fullName = student.fullName,
basis = student.basis,
+ status = student.status.name,
+ enrollmentYear = student.enrollmentYear,
mustChangePassword = account.mustChangePassword,
tempPassword = account.tempPassword,
)
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
- return GroupDetail(group.id.toString(), group.name, group.taskDurationMin, rows)
+ return GroupDetail(
+ id = group.id.toString(),
+ name = group.name,
+ stream = group.stream,
+ course = group.course,
+ number = group.number,
+ degree = group.degree,
+ enrollmentYear = group.enrollmentYear,
+ status = group.status.name,
+ taskDurationMin = group.taskDurationMin,
+ students = rows,
+ )
}
/** Список всех пользователей (студентов) с фильтром по подстроке. */
@@ -286,6 +256,8 @@ class GroupService(
studentNo = s.studentNo,
fullName = s.fullName,
basis = s.basis,
+ status = s.status.name,
+ enrollmentYear = s.enrollmentYear,
groupId = groupInfo?.first?.toString(),
groupName = groupInfo?.second,
)
@@ -341,27 +313,69 @@ class GroupService(
): ExportFile {
val detail = detail(groupId)
val safeName = detail.name.replace(Regex("[^\\p{L}\\p{N}._-]+"), "_")
+ val year = detail.enrollmentYear?.let { " ($it)" }.orEmpty()
return when (format.lowercase()) {
"csv" ->
ExportFile(
bytes = CredentialsExporter.csv(detail.students),
- filename = "credentials_$safeName.csv",
+ filename = "credentials_$safeName$year.csv",
contentType = "text/csv; charset=utf-8",
)
"xlsx" ->
ExportFile(
bytes = CredentialsExporter.xlsx(detail.students),
- filename = "credentials_$safeName.xlsx",
+ filename = "credentials_$safeName$year.xlsx",
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
else -> throw BadRequestException("Формат должен быть xlsx или csv")
}
}
+ private fun ensureStudentNumberAvailable(studentNo: String) {
+ if (students.existsByStudentNo(studentNo)) {
+ throw ConflictException("Студент с номером $studentNo уже существует")
+ }
+ if (accounts.existsByLogin(studentNo)) {
+ throw ConflictException("Логин $studentNo уже занят другим аккаунтом")
+ }
+ }
+
+ private fun activeGroup(groupId: UUID): GroupEntity {
+ val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
+ if (group.status != GroupStatus.ACTIVE) {
+ throw BadRequestException("Группа ${group.name} не активна")
+ }
+ return group
+ }
+
+ private fun recordHistory(
+ studentNo: UUID,
+ fromGroupId: UUID?,
+ toGroupId: UUID?,
+ action: MembershipAction,
+ actorAccountId: UUID?,
+ ) {
+ history.save(
+ MembershipHistory(
+ studentId = studentNo,
+ fromGroupId = fromGroupId,
+ toGroupId = toGroupId,
+ action = action,
+ actorAccountId = actorAccountId,
+ ),
+ )
+ }
+
private fun summary(group: GroupEntity): GroupSummary =
GroupSummary(
id = group.id.toString(),
name = group.name,
+ stream = group.stream,
+ course = group.course,
+ number = group.number,
+ degree = group.degree,
+ enrollmentYear = group.enrollmentYear,
+ status = group.status.name,
studentCount = enrollments.countByGroupId(group.id),
taskDurationMin = group.taskDurationMin,
)
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt
index c780281..c650699 100644
--- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt
@@ -5,13 +5,23 @@ import jakarta.validation.constraints.NotBlank
data class GroupSummary(
val id: String,
val name: String,
+ val stream: String? = null,
+ val course: Short? = null,
+ val number: Short? = null,
+ val degree: String? = null,
+ val enrollmentYear: Short? = null,
+ val status: String = "ACTIVE",
val studentCount: Long,
val taskDurationMin: Int,
)
data class CreateGroupRequest(
- @field:NotBlank(message = "Укажите название группы")
- val name: String,
+ val name: String? = null,
+ val stream: String? = null,
+ val course: Short? = null,
+ val number: Short? = null,
+ val degree: String? = null,
+ val enrollmentYear: Short? = null,
)
data class CreateStudentRequest(
@@ -30,6 +40,8 @@ data class StudentRow(
val studentNo: String,
val fullName: String,
val basis: String,
+ val status: String = "ACTIVE",
+ val enrollmentYear: Short? = null,
val mustChangePassword: Boolean,
val tempPassword: String?,
)
@@ -37,6 +49,12 @@ data class StudentRow(
data class GroupDetail(
val id: String,
val name: String,
+ val stream: String? = null,
+ val course: Short? = null,
+ val number: Short? = null,
+ val degree: String? = null,
+ val enrollmentYear: Short? = null,
+ val status: String = "ACTIVE",
val taskDurationMin: Int,
val students: List,
)
@@ -49,10 +67,17 @@ data class Credential(
)
data class ImportResult(
- val groupId: String,
- val groupName: String,
- val added: List,
- val updated: List,
+ val applied: Boolean = true,
+ val groupId: String? = null,
+ val groupName: String? = null,
+ val added: List = emptyList(),
+ val updated: List = emptyList(),
+ val errors: List = emptyList(),
+)
+
+data class ImportError(
+ val row: Int,
+ val message: String,
)
data class ResetPasswordResponse(
@@ -75,6 +100,8 @@ data class AdminUser(
val studentNo: String,
val fullName: String,
val basis: String,
+ val status: String = "ACTIVE",
+ val enrollmentYear: Short? = null,
val groupId: String?,
val groupName: String?,
)
diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentImportParser.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentImportParser.kt
index 8af09c7..aa71c74 100644
--- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentImportParser.kt
+++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentImportParser.kt
@@ -18,11 +18,45 @@ data class ParsedStudent(
get() = listOf(lastName, firstName, middleName).filter { it.isNotBlank() }.joinToString(" ")
}
-data class ParsedGroup(
- val groupName: String,
- val students: List,
+data class ImportRowError(
+ val row: Int,
+ val message: String,
)
+data class ParsedGroup(
+ val cohort: GroupCohort?,
+ val students: List,
+ val errors: List,
+)
+
+private data class HeaderRule(
+ val key: String,
+ val exact: Set = emptySet(),
+ val prefixes: List = emptyList(),
+) {
+ fun matches(value: String): Boolean = value in exact || prefixes.any { value.startsWith(it) }
+}
+
+private val HEADER_RULES =
+ listOf(
+ HeaderRule("stream", exact = setOf("поток"), prefixes = listOf("поток ")),
+ HeaderRule("course", exact = setOf("курс", "курс обучения")),
+ HeaderRule("groupNumber", exact = setOf("номер", "номер группы")),
+ HeaderRule("degree", prefixes = listOf("степень")),
+ HeaderRule("year", exact = setOf("год"), prefixes = listOf("год набора")),
+ HeaderRule(
+ "studentNo",
+ exact = setOf("№"),
+ prefixes = listOf("№ ", "студенч", "зачёт", "зачет", "номер студента"),
+ ),
+ HeaderRule("fullName", prefixes = listOf("фио", "фамилия имя")),
+ HeaderRule("lastName", prefixes = listOf("фамили")),
+ HeaderRule("firstName", exact = setOf("имя"), prefixes = listOf("имя ")),
+ HeaderRule("middleName", prefixes = listOf("отчеств")),
+ HeaderRule("basis", prefixes = listOf("база", "основ")),
+ HeaderRule("note", prefixes = listOf("примеч")),
+ )
+
@Component
class StudentImportParser {
fun parse(
@@ -30,11 +64,18 @@ class StudentImportParser {
bytes: ByteArray,
): ParsedGroup {
val lower = filename.lowercase()
- return when {
- lower.endsWith(".csv") -> parseRows(filename, parseCsv(bytes))
- lower.endsWith(".xls") || lower.endsWith(".xlsx") -> parseRows(filename, parseExcel(bytes))
- else -> throw BadRequestException("Поддерживаются файлы .xls, .xlsx и .csv")
+ val rows =
+ when {
+ lower.endsWith(".csv") -> parseCsv(bytes)
+ lower.endsWith(".xls") || lower.endsWith(".xlsx") -> parseExcel(bytes)
+ else -> throw BadRequestException("Поддерживаются файлы .xls, .xlsx и .csv")
+ }
+ val headerIndex = rows.indexOfFirst { row -> mapColumns(row).isNotEmpty() }
+ if (headerIndex < 0) {
+ throw BadRequestException("Не найдена шапка таблицы. Нужны столбцы: поток, курс, номер, степень, год набора, ФИО, №.")
}
+ val columns = requireColumns(rows[headerIndex])
+ return parseRows(rows, headerIndex, columns)
}
private fun parseExcel(bytes: ByteArray): List> {
@@ -55,9 +96,7 @@ class StudentImportParser {
private fun parseCsv(bytes: ByteArray): List> {
val text = bytes.toString(Charsets.UTF_8).removePrefix("\uFEFF")
val lines = text.split(Regex("\r?\n")).filter { it.isNotBlank() }
- if (lines.isEmpty()) {
- throw BadRequestException("Файл пуст")
- }
+ if (lines.isEmpty()) throw BadRequestException("Файл пуст")
val delimiter = detectDelimiter(lines.first())
return lines.map { splitCsvLine(it, delimiter) }
}
@@ -89,101 +128,228 @@ class StudentImportParser {
}
private fun parseRows(
- filename: String,
rows: List>,
+ headerIndex: Int,
+ columns: Map,
): ParsedGroup {
- val headerIndex = requireHeader(rows)
- val columns = requireColumns(rows[headerIndex])
- val students = rows.drop(headerIndex + 1).mapNotNull { parseStudent(it, columns) }
- if (students.isEmpty()) {
+ val errors = mutableListOf()
+ val students = mutableListOf()
+ val seenStudentNos = mutableMapOf()
+ var cohort: GroupCohort? = null
+
+ rows.drop(headerIndex + 1).forEachIndexed { index, row ->
+ if (row.all { it.isBlank() }) return@forEachIndexed
+ val rowNumber = headerIndex + index + 2
+ val rowCohort = parseCohort(row, columns, rowNumber, errors)
+ if (rowCohort != null) {
+ if (cohort == null) {
+ cohort = rowCohort
+ } else if (cohort != rowCohort) {
+ errors += ImportRowError(rowNumber, "Все строки файла должны относиться к одной группе")
+ }
+ }
+ val student = parseStudent(row, columns, rowNumber, errors, seenStudentNos)
+ if (student != null) students += student
+ }
+
+ if (students.isEmpty() && errors.isEmpty()) {
throw BadRequestException("В таблице не найдено ни одного студента")
}
- return ParsedGroup(groupNameFrom(filename, rows), students)
+ return ParsedGroup(cohort = cohort, students = students, errors = errors)
}
- private fun requireHeader(rows: List>): Int {
- val headerIndex = rows.indexOfFirst { row -> row.any { isStudentHeader(it) } }
- if (headerIndex < 0) {
- throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.")
- }
- return headerIndex
+ private fun parseCohort(
+ row: List,
+ columns: Map,
+ rowNumber: Int,
+ errors: MutableList,
+ ): 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): Map {
- val columns = mapColumns(header)
- if (columns["studentNo"] == null) {
- throw BadRequestException("Не найден столбец «Студенческий номер»")
- }
- return columns
+ private fun validateRequiredCohortFields(
+ fields: CohortFields,
+ rowNumber: Int,
+ errors: MutableList,
+ ) {
+ 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,
+ ) {
+ 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,
+ ) {
+ if (degree.isNotBlank() && degree !in setOf("БО", "МО")) {
+ errors += ImportRowError(rowNumber, "Степень должна быть БО или МО")
+ }
+ if (course != null && degree in setOf("БО", "МО") && !GroupNameParser.validCourse(degree, course.toShort())) {
+ errors += ImportRowError(rowNumber, "Курс $course недопустим для степени $degree")
+ }
+ }
+
+ private data class CohortFields(
+ val stream: String,
+ val course: String,
+ val number: String,
+ val degree: String,
+ val year: String,
+ )
+
private fun parseStudent(
row: List,
columns: Map,
+ rowNumber: Int,
+ errors: MutableList,
+ seenStudentNos: MutableMap,
): ParsedStudent? {
- fun cell(key: String): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
- val studentNo = normalizeNumber(cell("studentNo"))
- val lastName = cell("lastName")
- if (studentNo.isBlank() && lastName.isBlank()) return null
+ val before = errors.size
+ val studentNo = normalizeNumber(cell(row, columns, "studentNo"))
if (studentNo.isBlank()) {
- throw BadRequestException("В одной из строк отсутствует студенческий номер")
+ errors += ImportRowError(rowNumber, "Не заполнен № студента")
+ } else {
+ val previous = seenStudentNos.put(studentNo, rowNumber)
+ if (previous != null) {
+ errors += ImportRowError(rowNumber, "№ $studentNo уже встречается в строке $previous")
+ }
}
+
+ val fullName = cell(row, columns, "fullName")
+ val lastName: String
+ val firstName: String
+ val middleName: String
+ if (fullName.isNotBlank()) {
+ val parts = fullName.split(Regex("\\s+")).filter { it.isNotBlank() }
+ lastName = parts.getOrElse(0) { "" }
+ firstName = parts.getOrElse(1) { "" }
+ middleName = parts.drop(2).joinToString(" ")
+ } else {
+ lastName = cell(row, columns, "lastName")
+ firstName = cell(row, columns, "firstName")
+ middleName = cell(row, columns, "middleName")
+ }
+ if (lastName.isBlank()) errors += ImportRowError(rowNumber, "Не заполнено ФИО")
+
+ if (errors.size > before) return null
return ParsedStudent(
studentNo = studentNo,
lastName = lastName,
- firstName = cell("firstName"),
- middleName = cell("middleName"),
- basis = cell("basis"),
- note = cell("note"),
+ firstName = firstName,
+ middleName = middleName,
+ basis = cell(row, columns, "basis"),
+ note = cell(row, columns, "note"),
)
}
- private fun isStudentHeader(value: String): Boolean {
- val normalized = value.lowercase().replace("\u00a0", " ").trim()
- return normalized.startsWith("студен") ||
- normalized.startsWith("студ.") ||
- normalized.startsWith("зачёт") ||
- normalized.startsWith("зачет")
+ private fun requireColumns(header: List): Map {
+ val columns = mapColumns(header)
+ val missing = mutableListOf()
+ val names =
+ mapOf(
+ "stream" to "поток",
+ "course" to "курс",
+ "groupNumber" to "номер",
+ "degree" to "степень",
+ "year" to "год набора",
+ "studentNo" to "№",
+ )
+ names.forEach { (key, title) ->
+ if (columns[key] == null) missing += title
+ }
+ if (columns["fullName"] == null && columns["lastName"] == null) missing += "ФИО"
+ if (missing.isNotEmpty()) {
+ throw BadRequestException("Не найдены столбцы: ${missing.joinToString(", ")}. Проверьте шапку таблицы.")
+ }
+ return columns
}
private fun mapColumns(header: List): Map {
val result = mutableMapOf()
header.forEachIndexed { index, raw ->
- val value = raw.lowercase().replace("\u00a0", " ").trim()
-
- fun putIfAbsent(
- key: String,
- vararg prefixes: String,
- ) {
- if (key !in result && prefixes.any { value.startsWith(it) }) {
- result[key] = index
- }
- }
- putIfAbsent("studentNo", "студенч", "студ.", "студбил", "зачёт", "зачет")
- putIfAbsent("lastName", "фамили")
- putIfAbsent("firstName", "имя")
- putIfAbsent("middleName", "отчеств")
- putIfAbsent("basis", "основ")
- putIfAbsent("note", "примеч")
+ val value = normalizeHeader(raw)
+ HEADER_RULES
+ .firstOrNull { it.matches(value) }
+ ?.let { result.putIfAbsent(it.key, index) }
}
return result
}
- private fun normalizeNumber(value: String): String = if (value.endsWith(".0")) value.dropLast(2) else value
+ private fun normalizeHeader(value: String): String =
+ value
+ .lowercase()
+ .replace('\u00a0', ' ')
+ .trim()
+ .replace(Regex("\\s+"), " ")
- private fun groupNameFrom(
- filename: String,
- rows: List>,
- ): String {
- val groupRegex = Regex("[А-ЯA-Z][А-ЯA-Za-z]*[- ]?\\d{1,4}[А-ЯA-Za-z]*")
- for (row in rows.take(12)) {
- for (cell in row) {
- val value = cell.trim()
- if (value.length in 2..30 && groupRegex.matches(value)) {
- return value
- }
- }
- }
- return filename.substringBeforeLast('.').trim().ifBlank { "Группа" }
- }
+ private fun cell(
+ row: List,
+ columns: Map,
+ key: String,
+ ): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
+
+ private fun parseInt(value: String): Int? = normalizeNumber(value).takeIf { it.isNotBlank() }?.toIntOrNull()
+
+ private fun normalizeNumber(value: String): String = if (value.endsWith(".0")) value.dropLast(2) else value
}
diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/GroupNameParserTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/GroupNameParserTest.kt
new file mode 100644
index 0000000..8a0adca
--- /dev/null
+++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/GroupNameParserTest.kt
@@ -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))
+ }
+}
diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/GroupServiceIntegrationTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/GroupServiceIntegrationTest.kt
new file mode 100644
index 0000000..c85da8f
--- /dev/null
+++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/GroupServiceIntegrationTest.kt
@@ -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()
+}
diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/StudentImportParserTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/StudentImportParserTest.kt
new file mode 100644
index 0000000..877533c
--- /dev/null
+++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/StudentImportParserTest.kt
@@ -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()
+}
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt
index 8228e7f..a2d57ee 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt
@@ -3,6 +3,15 @@ package ru.ysu.lms.web.admin
// Формы админ-контроллеров: связываются через @ModelAttribute.
// Имена полей совпадают с именами полей в шаблонах (то же, что было в @RequestParam).
+data class CreateGroupForm(
+ val name: String? = null,
+ val stream: String? = null,
+ val course: Short? = null,
+ val number: Short? = null,
+ val degree: String? = null,
+ val enrollmentYear: Short? = null,
+)
+
data class CreateStudentForm(
val studentNo: String = "",
val lastName: String = "",
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt
index 29e3e62..cd74027 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt
@@ -19,6 +19,7 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.markdown.MarkdownRenderer
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
+import ru.ysu.lms.web.api.CreateGroupPayload
import ru.ysu.lms.web.api.DefenseDto
import ru.ysu.lms.web.api.GradeSetPayload
import ru.ysu.lms.web.api.GroupDetailDto
@@ -100,12 +101,22 @@ class AdminGroupController(
@PostMapping("/groups")
fun createGroup(
- @RequestParam name: String,
+ @ModelAttribute form: CreateGroupForm,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
- api.createGroup(token(session), name.trim())
+ api.createGroup(
+ token(session),
+ CreateGroupPayload(
+ name = form.name?.trim()?.takeIf { it.isNotBlank() },
+ stream = form.stream?.trim()?.takeIf { it.isNotBlank() },
+ course = form.course,
+ number = form.number,
+ degree = form.degree?.trim()?.takeIf { it.isNotBlank() },
+ enrollmentYear = form.enrollmentYear,
+ ),
+ )
redirect.addFlashAttribute("notice", "Группа создана")
"redirect:/admin/groups"
} catch (ex: ApiException) {
@@ -116,21 +127,19 @@ class AdminGroupController(
@PostMapping("/groups/import")
fun importGroup(
@RequestParam("file") file: MultipartFile,
- @RequestParam(name = "name", required = false) name: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
if (file.isEmpty) throw ApiException(400, "Выберите файл со списком группы")
- val result =
- api.importGroup(
- token(session),
- name,
- file.originalFilename ?: "group.xlsx",
- file.bytes,
- )
+ val result = api.importGroup(token(session), file.originalFilename ?: "group.xlsx", file.bytes)
redirect.addFlashAttribute("importResult", result)
- "redirect:/admin/groups/${result.groupId}"
+ if (!result.applied) {
+ redirect.addFlashAttribute("error", "Импорт не применён: исправьте ошибки в файле")
+ "redirect:/admin/groups"
+ } else {
+ "redirect:/admin/groups/${result.groupId}"
+ }
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups"
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminStudentController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminStudentController.kt
index 07b8358..1dc9b24 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminStudentController.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminStudentController.kt
@@ -129,7 +129,7 @@ class AdminStudentController(
): String =
try {
api.unenrollStudent(token(session), groupId, studentId)
- redirect.addFlashAttribute("notice", "Студент отчислён из группы (аккаунт и данные сохранены)")
+ redirect.addFlashAttribute("notice", "Студент отчислен: аккаунт и данные сохранены, вход закрыт")
"redirect:/admin/groups/$groupId"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt
index 83f75cc..850135a 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt
@@ -22,7 +22,7 @@ fun ApiClient.listGroups(token: String): List {
fun ApiClient.createGroup(
token: String,
- name: String,
+ payload: CreateGroupPayload,
): GroupDto =
call {
client
@@ -30,14 +30,13 @@ fun ApiClient.createGroup(
.uri("/api/v1/groups")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
- .body(CreateGroupPayload(name))
+ .body(payload)
.retrieve()
.body(GroupDto::class.java)
}
fun ApiClient.importGroup(
token: String,
- name: String?,
filename: String,
bytes: ByteArray,
): ImportResultDto {
@@ -48,9 +47,6 @@ fun ApiClient.importGroup(
override fun getFilename(): String = filename
},
)
- if (!name.isNullOrBlank()) {
- parts.add("name", name)
- }
return call {
client
.post()
diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt
index d7b1ceb..c35c949 100644
--- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt
+++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt
@@ -39,12 +39,23 @@ data class ChangePasswordPayload(
data class GroupDto(
val id: String = "",
val name: String = "",
+ val stream: String? = null,
+ val course: Short? = null,
+ val number: Short? = null,
+ val degree: String? = null,
+ val enrollmentYear: Short? = null,
+ val status: String = "ACTIVE",
val studentCount: Long = 0,
val taskDurationMin: Int = 30,
)
data class CreateGroupPayload(
- val name: String,
+ val name: String? = null,
+ val stream: String? = null,
+ val course: Short? = null,
+ val number: Short? = null,
+ val degree: String? = null,
+ val enrollmentYear: Short? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -57,10 +68,18 @@ data class CredentialDto(
@JsonIgnoreProperties(ignoreUnknown = true)
data class ImportResultDto(
- val groupId: String = "",
- val groupName: String = "",
+ val applied: Boolean = true,
+ val groupId: String? = null,
+ val groupName: String? = null,
val added: List = emptyList(),
val updated: List = emptyList(),
+ val errors: List = emptyList(),
+)
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class ImportErrorDto(
+ val row: Int = 0,
+ val message: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -69,6 +88,8 @@ data class StudentRowDto(
val studentNo: String = "",
val fullName: String = "",
val basis: String = "",
+ val status: String = "ACTIVE",
+ val enrollmentYear: Short? = null,
val mustChangePassword: Boolean = false,
val tempPassword: String? = null,
)
@@ -77,6 +98,12 @@ data class StudentRowDto(
data class GroupDetailDto(
val id: String = "",
val name: String = "",
+ val stream: String? = null,
+ val course: Short? = null,
+ val number: Short? = null,
+ val degree: String? = null,
+ val enrollmentYear: Short? = null,
+ val status: String = "ACTIVE",
val taskDurationMin: Int = 30,
val students: List = emptyList(),
)
@@ -97,6 +124,8 @@ data class AdminUserDto(
val studentNo: String = "",
val fullName: String = "",
val basis: String = "",
+ val status: String = "ACTIVE",
+ val enrollmentYear: Short? = null,
val groupId: String? = null,
val groupName: String? = null,
)
diff --git a/web/src/main/resources/templates/admin/group.html b/web/src/main/resources/templates/admin/group.html
index 18aaa93..df7fd36 100644
--- a/web/src/main/resources/templates/admin/group.html
+++ b/web/src/main/resources/templates/admin/group.html
@@ -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)) ? 'лабораторные' : 'лабораторных')}">лабораторных
с открытыми оценками
+
+ Набор 2025 ·
+ —
+
+ —
@@ -184,7 +191,7 @@
title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить
diff --git a/web/src/main/resources/templates/admin/groups.html b/web/src/main/resources/templates/admin/groups.html
index 6f4df36..f35f513 100644
--- a/web/src/main/resources/templates/admin/groups.html
+++ b/web/src/main/resources/templates/admin/groups.html
@@ -13,16 +13,45 @@
+
+ Импорт не применён
+ Исправьте ошибки в файле и загрузите его снова. Данные не изменены.
+
+
+
- | Название | Студентов | Пароли |
+
+
+ | Название |
+ Год набора |
+ Статус |
+ Студентов |
+ Пароли |
+
+
| — |
+ — |
+
+ —
+ |
0 |
XLSX·CSV |
- | Групп пока нет. |
+ | Групп пока нет. |
@@ -34,26 +63,44 @@
- |