Files
LMS/docs/07-groups-courses.md

532 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 07. Группы, курсы и преподаватели, повышение, интеграция (ТЗ)
**Статус:** проект согласован, реализация ведётся по плану
[08-groups-courses-plan.md](08-groups-courses-plan.md).
Документ описывает целевую модель, схему преобразований БД, правила и граничные случаи.
## 0. Решения, принятые при планировании (2026-10-23)
При противоречии с разделами 1–14 приоритет у этого раздела и плана
[08-groups-courses-plan.md](08-groups-courses-plan.md).
1. **«Общих» лабораторных нет.** Все лабы принадлежат курсу. Текущие лабы переносятся
в курс «Промышленное программирование на языке C»; все текущие активные группы
привязываются к нему. `labs.course_id` сначала nullable, после переноса — `NOT NULL`.
2. **Курс в URL — целочисленный `course_no`.** UUID остаётся первичным ключом;
`courses.course_no` — `BIGINT GENERATED ALWAYS AS IDENTITY`, уникальный и неизменяемый.
Курсовые материалы открываются по `/learn/course/{course_no}/...`.
Все текущие материалы переносятся в курс C и доступны только студентам его групп,
преподавателям курса и админу. Старые ссылки не поддерживаются.
`audience` применяется только к будущим глобальным материалам (`course_id IS NULL`).
3. **Статусы и вход.** `accounts.status (ACTIVE|ARCHIVED)` — для всех пользователей;
`students.status (ACTIVE|ACADEMIC_LEAVE|EXPELLED|GRADUATED|ARCHIVED)` — академический.
Вход закрыт для `EXPELLED`, `GRADUATED`, `ARCHIVED` и архивных аккаунтов.
`ACADEMIC_LEAVE` — вход в режиме только чтения. Web перепроверяет статус с кэшем
30–60 секунд; сервисы проверяют доступ к курсу при каждом запросе.
4. **Журнал повышения.** `promotion_runs` + `promotion_run_items` + `membership_history`.
Dry-run сохраняется как план; применение идемпотентно; откат — по элементам журнала
с проверкой конфликтов. 31 августа в 00:00 МСК автоматически строится dry-run;
применение — только вручную админом.
5. **Профиль студента курса.** Преподаватель своего курса открывает профиль студента
по клику на ФИО или отдельной кнопке: статистика по лабам, комментарии преподавателей,
скачивание файлов любой попытки сдачи. Подробно — §5.1.
6. **Объём.** Полный ТЗ, этапы 0–6, один релиз с внутренними merge gate.
## 1. Зафиксированные решения
1. Сущность **курс** живёт в `academic-service`; владелец — **админ**, преподавателей на курс
**назначает/снимает админ**, состав меняется во времени; у преподавателя может быть несколько курсов.
2. Группа хранит `stream`, `course`, `number`, `degree`, `enrollment_year`. Имя группы — производное
(пересчитывается при повышении), **не уникально**; уникальность — по
`(stream, degree, number, course, enrollment_year)`. Одноимённые группы разных наборов сосуществуют
и различаются годом набора.
3. **Повышение курса** — инкремент `course` у самой группы (без пересоздания и копирования студентов).
Лимиты: БО 1–4, МО 1–2; при достижении предела — выпуск.
4. **Академический отпуск**: студент не следует за группой. Переводится в группу
`(stream, number, degree, course = прошлый курс)` с
`enrollment_year = текущий_год − (прошлый_курс − 1)`; если такой группы нет — **создаётся**
(кейс «был на 1 курсе и ушёл в академ» → группа 1 курса с годом набора на 1 больше).
5. **Материалы вне курса** имеют аудиторию: `ALL` (видят все, **включая неавторизованных**),
`STUDENTS`, `TEACHERS`. Глобальные материалы публикует **только админ**.
6. **Лабораторные** можно **копировать** в другой курс; **переносить между курсами нельзя**.
7. **Выпуск**: БО-4 / МО-2 → группа и студенты `GRADUATED`, вход закрыт, группа замораживается.
8. Для пользователя доступны обе операции: **архив** и **полное удаление**; удаление с историей —
с усиленным подтверждением.
9. **Импорт Excel** — только админ. Ключ слияния — **№ студента** (`student_no`, уникален);
ФИО не является ключом (дубликаты допустимы, в UI различаем по №).
10. Внешние идентификаторы под «Контингент»/`next.uniyar.ac.ru` сейчас не заводим — добавим миграцией;
интеграция — отдельный сервис, позже.
11. Выгрузки используют **текущее имя группы + год набора**: `ПИ-21БО (2025)`.
**Разбор имени группы:** `^(.+)-(\d)(\d+)(БО|МО)$` → `stream` (буквы/дефисы), `course` (одна цифра),
`number` (остальные цифры), `degree`. Примеры: `ПИ-11БО` → ПИ/1/1/БО; `ИВТ-ТОП-12БО` → ИВТ-ТОП/1/2/БО;
`ПИ-101БО` → курс 1, номер 01 (ведущие нули допустимы). Разбор выполняется при создании/импорте,
далее живём по полям; имя храним для отображения/выгрузок.
---
## 2. Текущее состояние (as-is)
### identity
| Таблица | Ключевое |
|---|---|
| `accounts(id, login UNIQUE, password_hash, role, must_change_password, temp_password, created_at, updated_at)` | `CHECK role IN ('ADMIN','STUDENT')` |
| `students(account_id PK→accounts, student_no UNIQUE, last_name, first_name, middle_name, basis, note)` | нет статусов |
| `groups(id, name UNIQUE, task_duration_min, created_at)` | имя глобально уникально, нет потока/курса/номера/года |
| `enrollments(id, group_id→groups, student_id→students UNIQUE, enrolled_at)` | **уже «один студент — одна группа»** |
| `settings(key, value)` | — |
### academic
| Таблица | Ключевое |
|---|---|
| `chapters(id, title, slug UNIQUE, position, published)` | **`slug` уникален глобально** |
| `sections(id, chapter_id→chapters, title, slug, position, published)` | `UNIQUE(chapter_id, slug)` |
| `articles(id, section_id→sections, title, slug, body_md, position, published)` | `UNIQUE(section_id, slug)` |
| `article_assets(id, article_id→articles, filename, stored_path, mime, size, created_at)` | активы не имеют признака видимости |
| `semesters(id, title, year, active)` | — |
| `labs(id, number, title, semester_id→semesters NOT NULL, description_md, deadline_at, defense_at, points_max, status, created_at)` | `UNIQUE(number, semester_id)` |
| `lab_levels(id, lab_id→labs, code, title, description, position, points_max)` | — |
| `variants(id, lab_id→labs, lab_level_id→lab_levels, number, title, statement_md, position)` | — |
| `variant_examples(id, variant_id→variants, position, input, output, explanation)` | — |
> Историческая справка: таблица `courses` существовала в `academic V2`, но была удалена в `V4`
> (материалы перешли на модель `chapters → sections → articles`). Курс нужно создать заново.
### progress
Данные привязаны к `lab_id` и студенту (сдачи, оценки, комментарии, защиты, назначения вариантов,
публикации). Схема **в основном не меняется**: доступ регулируется по цепочке
`lab → course → (преподаватель | группа)`. Для профиля студента курса добавляются
`grade_comments.author_account_id` (nullable) и `lab_grades.updated_by_account_id`
(nullable); существующий индекс `(student_id, lab_id)` (`grade_comments_grade_idx`)
переиспользуется, дополнительно добавляется индекс `lab_grades(lab_id)`.
Остальное — рантайм-проверки.
---
## 3. Целевая модель (to-be)
```mermaid
erDiagram
ACCOUNTS ||--o| STUDENTS : account
ACCOUNTS ||--o{ COURSE_TEACHERS : teacher
GROUPS ||--o{ MEMBERSHIP_HISTORY : history
STUDENTS ||--o{ MEMBERSHIP_HISTORY : history
COURSES ||--o{ COURSE_TEACHERS : assigned
COURSES ||--o{ COURSE_GROUPS : enrolled
GROUPS ||--o{ COURSE_GROUPS : enrolled
COURSES ||--o{ LABS : owns
COURSES ||--o{ CHAPTERS : owns
CHAPTERS ||--o{ SECTIONS : contains
SECTIONS ||--o{ ARTICLES : contains
```
### identity
- `accounts.role`: `ADMIN | TEACHER | STUDENT` (обновить `CHECK`).
- `students`: `+ status (ACTIVE | ACADEMIC_LEAVE | EXPELLED | GRADUATED | ARCHIVED)`, `+ enrollment_year`.
- `groups`:
`+ stream`, `+ course`, `+ number`, `+ degree (БО|МО)`, `+ enrollment_year`, `+ status (ACTIVE|GRADUATED|ARCHIVED)`;
`name` — без UNIQUE. Уникальность: `(stream, degree, number, course, enrollment_year)`.
Constraint: `course` в диапазоне по `degree` (БО 1–4, МО 1–2).
- `membership_history(id, student_id, from_group_id, to_group_id, action, reason, actor_account_id, at)` —
переводы, академ, возврат, выпуск, отчисление, восстановление.
### academic
- `courses(id, course_no, title, owner_account_id→accounts, status (ACTIVE|ARCHIVED), created_at, updated_at)`;
`course_no` — `BIGINT GENERATED ALWAYS AS IDENTITY`, уникальный, для URL.
- `course_teachers(id, course_id, account_id, assigned_at, removed_at)` — доступ = `removed_at IS NULL`; история сохраняется.
- `course_groups(id, course_id, group_id, added_at, removed_at)`.
- `labs`: `+ course_id` (nullable только на время миграции, затем `NOT NULL`); «общих» лаб нет;
новый уникальный ключ `(number, course_id)` для лаб курса.
- Материалы: только у **корня** (`chapters`) добавляем `+ course_id` (nullable) и `+ audience`
(`ALL | STUDENTS | TEACHERS`, применяется при `course_id IS NULL`); `sections`/`articles` наследуют
видимость от главы. Текущие материалы переносятся в курс C и получают `course_id`.
- `chapters.slug`: снять глобальный UNIQUE, ввести два частичных индекса (глобальные и внутри курса).
- `article_assets`: видимость наследуется от статьи → раздела → главы (см. §6).
---
## 4. Схема преобразований (миграции)
> Все миграции — аддитивные, с бэкфиллом и проверкой; откат — отдельным скриптом/миграцией (drop колонок/таблиц).
> Бэкфилл «года набора» — **best-effort** с последующей проверкой админом.
### identity
**V4 — роли**
```sql
ALTER TABLE accounts DROP CONSTRAINT accounts_role_check;
ALTER TABLE accounts ADD CONSTRAINT accounts_role_check CHECK (role IN ('ADMIN','TEACHER','STUDENT'));
```
**V5 — группы: поля, бэкфилл, уникальность**
```sql
ALTER TABLE groups
ADD COLUMN stream varchar(50),
ADD COLUMN course smallint,
ADD COLUMN number smallint,
ADD COLUMN degree varchar(2),
ADD COLUMN enrollment_year smallint,
ADD COLUMN status varchar(16) NOT NULL DEFAULT 'ACTIVE';
-- Бэкфилл из name: ^(.+)-(\d)(\d+)(БО|МО)$ (в сервисе/скрипте, т.к. миграция SQL)
-- enrollment_year = academicYearStartYear - (course - 1) (best-effort, требует подтверждения)
UPDATE groups SET stream=..., course=..., number=..., degree=..., enrollment_year=... WHERE ...;
ALTER TABLE groups
ALTER COLUMN stream SET NOT NULL, ALTER COLUMN course SET NOT NULL,
ALTER COLUMN number SET NOT NULL, ALTER COLUMN degree SET NOT NULL;
ALTER TABLE groups DROP CONSTRAINT groups_name_key;
ALTER TABLE groups ADD CONSTRAINT groups_cohort_unique
UNIQUE (stream, degree, number, course, enrollment_year);
ALTER TABLE groups ADD CONSTRAINT groups_course_range CHECK (
(degree='БО' AND course BETWEEN 1 AND 4) OR (degree='МО' AND course BETWEEN 1 AND 2));
```
Риск: группы, не соответствующие шаблону имени → ставим на ручной разбор (отчёт до применения NOT NULL).
**V6 — студенты: статус и год набора**
```sql
ALTER TABLE students
ADD COLUMN status varchar(16) NOT NULL DEFAULT 'ACTIVE',
ADD COLUMN enrollment_year smallint;
UPDATE students s SET enrollment_year = g.enrollment_year FROM enrollments e JOIN groups g ON g.id=e.group_id WHERE e.student_id=s.account_id;
```
**V7 — история членства**
```sql
CREATE TABLE membership_history (
id uuid PRIMARY KEY DEFAULT uuidv7(),
student_id uuid NOT NULL REFERENCES students(account_id) ON DELETE CASCADE,
from_group_id uuid REFERENCES groups(id) ON DELETE SET NULL,
to_group_id uuid REFERENCES groups(id) ON DELETE SET NULL,
action varchar(32) NOT NULL, -- ENROLL | TRANSFER | ACADEMIC | RETURN | GRADUATE | EXPELL
reason text NOT NULL DEFAULT '',
actor_account_id uuid,
at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO membership_history(student_id, to_group_id, action, at)
SELECT student_id, group_id, 'ENROLL', enrolled_at FROM enrollments;
```
**V8–V9 — история членства и статусы аккаунтов** реализованы на этапе 1
(`membership_history`, `accounts.status`).
**V10 — журнал аудита (добавлен на этапе 6, `V10__audit_events.sql`)**
```sql
CREATE TABLE identity.audit_events (
id uuid PRIMARY KEY,
occurred_at timestamptz NOT NULL DEFAULT now(),
actor_account_id uuid,
actor_login varchar(100),
action varchar(64) NOT NULL,
entity_type varchar(64) NOT NULL,
entity_id varchar(128),
course_id uuid,
student_id uuid,
details text NOT NULL DEFAULT ''
);
CREATE INDEX audit_events_occurred_idx ON identity.audit_events (occurred_at DESC);
CREATE INDEX audit_events_action_idx ON identity.audit_events (action);
```
Внешних ключей нет: событие должно переживать удаление субъекта. Запись идёт
из сервисов через `POST /api/v1/internal/audit` (`X-Internal-Token`), чтение —
`GET /api/v1/audit` и `/admin/audit` только для админа.
### academic
**V6 — курс (пересоздание)**
```sql
CREATE TABLE courses (
id uuid PRIMARY KEY DEFAULT uuidv7(),
course_no bigint GENERATED ALWAYS AS IDENTITY UNIQUE NOT NULL,
title varchar(200) NOT NULL,
owner_account_id uuid NOT NULL,
status varchar(16) NOT NULL DEFAULT 'ACTIVE',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE course_teachers (
id uuid PRIMARY KEY DEFAULT uuidv7(),
course_id uuid NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
account_id uuid NOT NULL,
assigned_at timestamptz NOT NULL DEFAULT now(),
removed_at timestamptz
);
CREATE UNIQUE INDEX course_teachers_active_uidx ON course_teachers(course_id, account_id) WHERE removed_at IS NULL;
CREATE TABLE course_groups (
id uuid PRIMARY KEY DEFAULT uuidv7(),
course_id uuid NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
group_id uuid NOT NULL,
added_at timestamptz NOT NULL DEFAULT now(),
removed_at timestamptz
);
CREATE UNIQUE INDEX course_groups_active_uidx ON course_groups(course_id, group_id) WHERE removed_at IS NULL;
```
**V7 — лабораторные привязываем к курсу (nullable-фаза)**
```sql
-- Курс «Промышленное программирование на языке C» создаётся bootstrap-сервисом
-- после первого входа админа (owner = этот админ). В V7 только добавляем nullable-колонку.
ALTER TABLE labs ADD COLUMN course_id uuid REFERENCES courses(id) ON DELETE RESTRICT;
ALTER TABLE labs DROP CONSTRAINT labs_number_semester_unique;
CREATE UNIQUE INDEX labs_number_course_uidx ON labs (number, course_id);
CREATE INDEX labs_course_idx ON labs (course_id);
```
**V7a — bootstrap курса C (сервис, не SQL-миграция)**
- Создать курс «Промышленное программирование на языке C» с `owner_account_id` текущего
админа; первый созданный курс получит `course_no = 1` автоматически.
- Привязать все текущие активные группы к курсу (`course_groups`).
- Проставить `labs.course_id` и `chapters.course_id` для всех существующих записей.
- Проверить дубли `(number, course_id)` и показать отчёт, если они есть.
**V8 — материалы: курс и аудитория, слаг**
```sql
ALTER TABLE chapters ADD COLUMN course_id uuid REFERENCES courses(id) ON DELETE CASCADE;
ALTER TABLE chapters ADD COLUMN audience varchar(16); -- ALL | STUDENTS | TEACHERS (только при course_id IS NULL)
-- Bootstrap проставляет course_id = C для текущих глав; audience у курсовых глав остаётся NULL.
-- audience = 'ALL' используется только для будущих глобальных материалов.
ALTER TABLE chapters DROP CONSTRAINT chapters_slug_key;
CREATE UNIQUE INDEX chapters_slug_global_uidx ON chapters (slug) WHERE course_id IS NULL;
CREATE UNIQUE INDEX chapters_slug_course_uidx ON chapters (course_id, slug) WHERE course_id IS NOT NULL;
```
Обновить URL-схему материалов: глобальные — `/learn/{chapter}/{section}/{article}`;
курсовые — `/learn/course/{course_no}/{chapter}/{section}/{article}`.
Старые ссылки на текущие материалы не поддерживаются (решение 2026-10-23).
**V9 — активы: наследуемая видимость**
Признак видимости актив наследует от статьи→раздела→главы (course/audience). Требуется проверка доступа в
маршруте выдачи активов (см. §6). Отдельных колонок достаточно опционально; минимум — корректная авторизация.
> В реализации backfill связей вынесен в `V12__asset_links_backfill.sql`, а
> проверка доступа — в `/api/v1/content/assets/{name}` (см. [15](15-stage5-course-materials.md)).
**V10 — уникальное название курса (реальный файл `V10__course_title_unique.sql`)**
```sql
-- Защита от дублей курса при конкурентном bootstrap и повторном создании.
CREATE UNIQUE INDEX courses_title_lower_uidx ON courses (lower(title));
```
Сервис дополнительно проверяет название без учёта регистра и возвращает `409`
на дубликат; индекс закрывает гонку между проверкой и вставкой.
**V11 — аудитория глобальных глав (реальный файл `V11__global_audience_all.sql`)**
```sql
-- Глобальные главы доступны публично; курсовые остаются с audience = NULL.
UPDATE academic.chapters SET audience = 'ALL' WHERE course_id IS NULL AND audience IS NULL;
```
Backfill нужен для глав, созданных до появления правила: публичное дерево
показывает только глобальные главы с `audience = 'ALL'`. Новые глобальные главы
создаются и импортируются сразу с `audience = 'ALL'`.
**Post-bootstrap шаг academic (вне Flyway) — курс C: завершение привязки лаб**
```sql
-- Выполняется сервисом CourseBootstrapService после переноса данных, когда все
-- лабы получили course_id. Обычный Flyway-старт применяет миграции раньше
-- бизнес-логики и не может дождаться создания курса и назначения владельца.
ALTER TABLE labs ALTER COLUMN course_id SET NOT NULL;
```
### progress
Схема **в основном не меняется**. Проверки доступа — рантайм (по `lab_id → course`).
**V13 — профиль студента курса: автор комментария**
```sql
ALTER TABLE grade_comments ADD COLUMN IF NOT EXISTS author_account_id uuid;
ALTER TABLE lab_grades ADD COLUMN IF NOT EXISTS updated_by_account_id uuid;
CREATE INDEX IF NOT EXISTS lab_grades_lab_idx ON lab_grades (lab_id);
```
V13 идемпотентна: если старая feature-миграция V10 уже применялась в dev-БД,
достаточно убрать её запись из истории Flyway (см. план реализации).
Индекс `grade_comments (student_id, lab_id)` уже существует (`grade_comments_grade_idx`).
Старые комментарии остаются без автора и отображаются как «автор не указан».
---
## 5. Роли и матрица доступа
| Кто | Доступ |
|---|---|
| **ADMIN** | всё: пользователи, группы, импорт Excel, перевод/академ/отчисление/выпуск, создание/архив курсов, назначение преподавателей, запуск и откат повышения, публикация глобальных материалов, архив/удаление |
| **TEACHER** | только **текущий свой курс** (`course_teachers.removed_at IS NULL`): материалы курса, лабы/уровни/варианты, распределение, приём лаб, оценки и комментарии, коды защиты, запись/снятие групп своего курса |
| **STUDENT** | материалы/лабы курсов, куда записана его активная группа (и только опубликованное), свои сдачи/оценки |
| **Аноним** | только материалы глав с `audience = ALL` |
Обязательные требования:
1. Скоуп проверяется **на сервисах**, а не только в web-BFF (иначе прямой вызов чужого `labId`/`articleId`/`groupId`).
2. Доступ отзывается **немедленно** (не хранить список курсов/групп в JWT).
3. Файлы: преподаватель — только сдачи своих курсов; студент — только свои; **активы** — по видимости материала.
### 5.1. Профиль студента курса (требование 2026-10-23)
Преподаватель текущего курса может открыть профиль студента:
- по клику на ФИО в ведомости группы;
- по отдельной кнопке «Профиль».
Доступ:
- преподаватель — только если он активен в `course_teachers` (`removed_at IS NULL`),
а студент входит в группу, привязанную к этому курсу (`course_groups.removed_at IS NULL`);
- админ — любой студент любого курса;
- студент — свой профиль и только опубликованные оценки/комментарии
(опционально; в обязательный объём не входит);
- аноним — доступа нет;
- проверка выполняется на сервисах; чужой курс/студент/сдача → `403` или `404`.
Состав профиля:
- карточка студента: ФИО, №, группа, статус, год набора;
- сводка по курсу: количество лаб, сдано, проверено, средний балл, защиты;
- таблица по каждой лабе курса: номер/название, вариант и уровень, число попыток,
дата последней попытки, оценка, статус публикации, статус защиты, общий комментарий;
- комментарии преподавателей по каждой лабе: текст, тон, автор, дата;
общий комментарий оценки и отдельные `grade_comments`;
- все попытки сдачи по каждой лабе: номер, дата, файлы, скачивание любой попытки
(ZIP и, при необходимости, отдельные файлы).
Ограничения:
- преподаватель видит все лабы своего курса, включая черновики, но не чужие курсы;
- студент видит только опубликованное;
- скачивание файлов не даёт доступа к чужим сдачам;
- профиль не заменяет ведомость: оценки по-прежнему выставляются в ведомости;
- старые комментарии без автора отображаются как «автор не указан»;
- аудит скачиваний реализован на этапе 6 (см. §5.2).
### 5.2. Аудит (реализовано на этапе 6)
Чувствительные действия пишутся в `identity.audit_events` (`V10`): создание и
сброс преподавателя, жизненный цикл и архив групп, повышение и удаление,
действия с курсами/группами/материалами/лабораториями, просмотр профиля
преподавателем или админом, скачивания, оценки/публикация, раздача вариантов
и защиты. Свой профиль студент открывает без записи в аудит, чтобы не
засорять журнал и не нагружать identity на горячем пути. Админ смотрит
журнал на `/admin/audit` с фильтром по действию; время отображается в МСК.
`membership_history` и `promotion_runs/items` остаются доменной историей и
дополняют общий журнал.
---
## 6. Материалы, публичный доступ и активы
- Аудитории: `ALL` (в т.ч. **неавторизованные**), `STUDENTS`, `TEACHERS`; глобальные публикует только админ.
- Нужны **публичные маршруты** для `ALL` без аутентификации. `STUDENTS`/`TEACHERS`/курсовые материалы
анонимно недоступны ни при каких условиях.
- **Активы (риск):** материалы ссылаются на файлы (`/api/v1/assets/**`). Для публичных глав активы должны
быть доступны, для приватных — нет. Решение: видимость актива наследуется от главы, и маршрут выдачи
активов обязан это проверять (либо отдельный публичный префикс для `ALL`). Без этого публичные материалы
будут ломаться, а приватные — утекать.
- Поисковая индексация: для `ALL` — по умолчанию разрешена; для остального — закрыть (robots/noindex).
---
## 7. Повышение курса (31 августа, МСК)
Псевдокод и правила:
1. Запуск только **ADMIN**, сначала **dry-run** (план + подтверждение).
2. Обработка детерминированная (для аудита — от старших курсов к младшим). Благодаря уникальности
`(…, course, enrollment_year)` коллизии имён при повышении **не возникают** (одноимённые группы разных
наборов сосуществуют легально).
3. Группы со `status='ACTIVE'` и `course < лимит(degree)` → `course += 1` (имя пересчитывается).
4. Группы на пределе (БО-4, МО-2) → `status='GRADUATED'`, имя фиксируется, из будущих прогонов исключаются.
5. Студенты `ACADEMIC_LEAVE` — по правилу §1.4 (перевод в группу курсом ниже; при отсутствии — создать).
6. `EXPELLED`, `ARCHIVED`, `GRADUATED` — не трогаем.
7. **Идемпотентность**: таблица `promotion_runs(year, mode, actor_account_id, started_at, finished_at, result)`;
повторный запуск за год не меняет данные.
8. **Догон** пропущенного запуска (сервер лежал 31.08) — кнопкой, с отчётом.
9. **Откат** по журналу (при ручных правках — требовать подтверждение).
10. Отчёт: список изменений и «заблокированных» кейсов.
Граничные случаи: одноимённые группы разных наборов; повышение при уже импортированном новом наборе
(допустимо, различаем годом); группы без студентов; ручные правки курса/номера до повышения (валидация
и предупреждение); академ на 1 курсе (создание целевой группы).
---
## 8. Академический отпуск
- Инициатор — только админ; фиксируется в `membership_history` (`action='ACADEMIC'`/`'RETURN'`).
- При повышении студент не следует за группой: переводится в `(stream, number, degree, course=прошлый курс)`,
`enrollment_year = текущий_год − (прошлый_курс − 1)`; целевая группа создаётся при отсутствии.
- Академ на 1 курсе: создаётся группа 1 курса с `enrollment_year` на 1 больше.
- Возврат: админ указывает курс возврата → зачисление в подходящую группу.
- Статус `student.status='ACADEMIC_LEAVE'`; вход сохраняется (или ограничивается — на усмотрение админа).
---
## 9. Импорт Excel (только админ)
- Шаблон: `поток | курс | номер | степень | год набора | ФИО | № | база`; проверка соответствия имени группы полям.
- Валидация: формат имени, диапазон курса по степени, год набора, **уникальность `№`**, пустые поля.
- Ключ слияния — `student_no`; ФИО не ключ (в UI рядом с ФИО показываем №).
- Транзакционно («всё или ничего») + **построчный отчёт** об ошибках; повторный импорт обновляет, не дублирует.
- Для нового набора генерируется группа по полям; при существующей «одноимённой» — берётся по году набора.
---
## 10. Копирование лабораторных
- Копирование лабы (с уровнями/вариантами) в **другой курс** → в целевом курсе создаётся **новая** лаба
(`number`/`title` можно скорректировать), уровни/варианты копируются с новыми id.
- Оценки, сдачи, защиты и распределения **не копируются** — они остаются с исходной лабой.
- Перенос лабы между курсами (смена `course_id`) **запрещён**.
---
## 11. Архив и удаление
- **Архив** (`ARCHIVED`): вход закрыт, история сохранена, не участвует в повышении и обычных списках.
- **Полное удаление**: только с **усиленным подтверждением** (ввод логина/ФИО); удаляет связанные данные
(текущее поведение).
- Выпуск/отчисление — отдельные статусы, не удаление.
---
## 12. Интеграция «Контингент» / next.uniyar.ac.ru (задел)
- Позже — **отдельный сервис-адаптер** (anti-corruption layer); внешняя система — источник истины по
курсу/группе/статусу; LMS — зеркало; синхронизация односторонняя + отчёт дрифта.
- Сейчас закладываем: `student_no` уникальный и стабильный (ключ склейки); внешние id добавим миграцией.
- Обязательно: идемпотентность, журнал синхронизаций, отчёт расхождений, обработка академа/переводов/отчислений,
устойчивость к недоступности внешней системы, ПДн.
---
## 13. Граничные случаи и риски (чек-лист реализации)
1. Скоуп-авторизация на сервисах + тесты «чужой ресурс» (все роли × эндпоинты).
2. Порядок и идемпотентность повышения; dry-run; откат; МСК; догон пропущенного запуска.
3. Одноимённые группы — уникальность по году набора; отображение с годом.
4. Академ vs групповой курс — правило перевода и автосоздание целевой группы.
5. Публичные `ALL`-материалы и **активы** — проверка видимости, иначе утечка/ломка.
6. `chapters.slug`: глобальный UNIQUE → частичные индексы (глобальные/курсовые) + новая URL-схема;
старые ссылки на текущие материалы не поддерживаются.
7. Миграция существующих лаб/материалов в курс C; «общих» лаб нет.
8. Немедленный отзыв доступа (преподаватель/группа/публикация материала).
9. Аудит-лог: переводы, академ, повышение, назначения преподавателей, публикации,
архив/удаление — реализован в `identity.audit_events` (`V10`), просмотр
`/admin/audit`.
10. Производительность: индексы `course_id`/`group_id`, пагинация материалов и групп.
11. Бэкфилл `enrollment_year` для существующих групп — приблизительный, требует подтверждения админом.
12. Лабы: уникальность номера — в рамках курса (`(number, course_id)`), прежний ключ по семестру снимается.
13. Профиль студента курса: скоуп по курсу, все попытки, комментарии с автором,
аудит скачиваний, отсутствие N+1.
14. Статус защиты в ведомости: различать «готова» (нажал студент) и «кончилось время»
(timeout), с разным цветом и подсказкой.
15. Резервное копирование: обязательный backup перед деплоем/миграцией,
ежедневный backup по расписанию, retention, `verify` и restore на staging.
---
## 14. Этапы
0. Схема БД и миграции (аддитивно, с бэкфиллом) — по §4.
1. Группы: поля, разбор имени, «одна активная группа», импорт Excel, перемещение студента (админ).
2. Курсы + роль `TEACHER` + назначения (с историей) + скоуп-авторизация и тесты.
2a. Профиль студента курса: статистика по лабам, комментарии, все попытки, скачивание файлов.
3. Привязка лаб/материалов к курсу, аудитории материалов, публичные `ALL`-маршруты и активы, миграция данных.
4. Запись/снятие групп на курс.
5. Повышение курса: dry-run, журнал, идемпотентность, откат, выпуск.
6. Академический отпуск/возврат, отчисление, выпуск, архив; усиленное удаление.
7. (Позже) сервис-адаптер «Контингент»/`next.uniyar.ac.ru`.
Открытых вопросов нет: все решения зафиксированы в §1.