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="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить
+ method="post" data-confirm="Отчислить этого студента из группы? Аккаунт, сдача и оценки сохранятся, но вход в систему будет закрыт.">
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 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

Имя группы будет собрано автоматически: поток + курс + номер + степень, например ПИ-11БО.

- +

Импорт студентов