diff --git a/docs/07-groups-courses.md b/docs/07-groups-courses.md new file mode 100644 index 0000000..2c5ebc8 --- /dev/null +++ b/docs/07-groups-courses.md @@ -0,0 +1,365 @@ +# 07. Группы, курсы и преподаватели, повышение, интеграция (ТЗ) + +**Статус:** проект согласован, к реализации не приступаем до отдельного сигнала. +Документ описывает целевую модель, схему преобразований БД, правила и граничные случаи. + +## 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 → (преподаватель | группа)`. + +--- + +## 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, title, owner_account_id→accounts, status (ACTIVE|ARCHIVED), created_at, updated_at)`. +- `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 — «общие» лабы), новый уникальный ключ `(number, course_id)` для лаб курса. +- Материалы: только у **корня** (`chapters`) добавляем `+ course_id` (nullable) и `+ audience` + (`ALL | STUDENTS | TEACHERS`, применяется при `course_id IS NULL`); `sections`/`articles` наследуют + видимость от главы. +- `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; +``` + +### academic + +**V6 — курс (пересоздание)** +```sql +CREATE TABLE courses ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + 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 — лабораторные привязываем к курсу** +```sql +-- «Курс по умолчанию» создаётся в сервисе (title='Общие лабораторные', owner=первый ADMIN) +ALTER TABLE labs ADD COLUMN course_id uuid REFERENCES courses(id) ON DELETE RESTRICT; +UPDATE labs SET course_id = (SELECT id FROM courses ORDER BY created_at LIMIT 1) WHERE course_id IS NULL; +ALTER TABLE labs ALTER COLUMN course_id SET NOT NULL; +DROP CONSTRAINT labs_number_semester_unique; +ALTER TABLE labs ADD CONSTRAINT labs_number_course_unique UNIQUE (number, course_id); +CREATE INDEX labs_course_idx ON labs (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) +UPDATE chapters SET audience = 'ALL' WHERE course_id IS NULL; -- сохранить прежнее поведение («видят все») + +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/{course}/{chapter}/{section}/{article}`; +старые ссылки на глобальные материалы сохраняются (совместимость). + +**V9 — активы: наследуемая видимость** +Признак видимости актив наследует от статьи→раздела→главы (course/audience). Требуется проверка доступа в +маршруте выдачи активов (см. §6). Отдельных колонок достаточно опционально; минимум — корректная авторизация. + +### progress +Схема **не меняется**. Проверки доступа — рантайм (по `lab_id → course`). + +--- + +## 5. Роли и матрица доступа + +| Кто | Доступ | +|---|---| +| **ADMIN** | всё: пользователи, группы, импорт Excel, перевод/академ/отчисление/выпуск, создание/архив курсов, назначение преподавателей, запуск и откат повышения, публикация глобальных материалов, архив/удаление | +| **TEACHER** | только **текущий свой курс** (`course_teachers.removed_at IS NULL`): материалы курса, лабы/уровни/варианты, распределение, приём лаб, оценки и комментарии, коды защиты, запись/снятие групп своего курса | +| **STUDENT** | материалы/лабы курсов, куда записана его активная группа (и только опубликованное), свои сдачи/оценки | +| **Аноним** | только материалы глав с `audience = ALL` | + +Обязательные требования: +1. Скоуп проверяется **на сервисах**, а не только в web-BFF (иначе прямой вызов чужого `labId`/`articleId`/`groupId`). +2. Доступ отзывается **немедленно** (не хранить список курсов/групп в JWT). +3. Файлы: преподаватель — только сдачи своих курсов; студент — только свои; **активы** — по видимости материала. + +--- + +## 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. Миграция существующих лаб/материалов без курса (курс по умолчанию / `audience='ALL'`). +8. Немедленный отзыв доступа (преподаватель/группа/публикация материала). +9. Аудит-лог: переводы, академ, повышение, назначения преподавателей, публикации, архив/удаление. +10. Производительность: индексы `course_id`/`group_id`, пагинация материалов и групп. +11. Бэкфилл `enrollment_year` для существующих групп — приблизительный, требует подтверждения админом. +12. Лабы: уникальность номера — в рамках курса (`(number, course_id)`), прежний ключ по семестру снимается. + +--- + +## 14. Этапы + +0. Схема БД и миграции (аддитивно, с бэкфиллом) — по §4. +1. Группы: поля, разбор имени, «одна активная группа», импорт Excel, перемещение студента (админ). +2. Курсы + роль `TEACHER` + назначения (с историей) + скоуп-авторизация и тесты. +3. Привязка лаб/материалов к курсу, аудитории материалов, публичные `ALL`-маршруты и активы, миграция данных. +4. Запись/снятие групп на курс. +5. Повышение курса: dry-run, журнал, идемпотентность, откат, выпуск. +6. Академический отпуск/возврат, отчисление, выпуск, архив; усиленное удаление. +7. (Позже) сервис-адаптер «Контингент»/`next.uniyar.ac.ru`. + +Открытых вопросов нет: все решения зафиксированы в §1. diff --git a/docs/README.md b/docs/README.md index 12819d3..562104c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,8 @@ Preview Mermaid Support) и в IntelliJ IDEA (плагин Mermaid). | [03-domain-model.md](03-domain-model.md) | Диаграммы классов по сервисам, ER-модель БД, модель web-слоя | | [04-sequences.md](04-sequences.md) | Диаграммы последовательностей: вход, импорт ЛР, раздача вариантов, сдача, защита, оценки, материалы | | [05-states.md](05-states.md) | Диаграммы состояний: защита, публикация оценок, статус ЛР, публикация материалов | +| [06-authoring.md](06-authoring.md) | Работа с учебными материалами: структура, markdown-редактор, импорт zip | +| [07-groups-courses.md](07-groups-courses.md) | ТЗ: группы/потоки, курсы и преподаватели, повышение курса, академический отпуск, интеграция с «Контингент» | ## Кратко о системе