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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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