docs: план, ADR и контракты групп и курсов

This commit is contained in:
2026-10-26 15:47:01 +03:00
parent 920d96a0f5
commit cde04487e8
6 changed files with 945 additions and 18 deletions

View File

@@ -1,8 +1,37 @@
# 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`; владелец — **админ**, преподавателей на курс
@@ -65,7 +94,10 @@
### progress
Данные привязаны к `lab_id` и студенту (сдачи, оценки, комментарии, защиты, назначения вариантов,
публикации). Схема **не меняется**: доступ регулируется по цепочке `lab → course → (преподаватель | группа)`.
публикации). Схема **в основном не меняется**: доступ регулируется по цепочке
`lab → course → (преподаватель | группа)`. Для профиля студента курса добавляется
`grade_comments.author_account_id` (nullable) и индекс `(student_id, lab_id)`;
остальное — рантайм-проверки.
---
@@ -97,13 +129,15 @@ erDiagram
переводы, академ, возврат, выпуск, отчисление, восстановление.
### academic
- `courses(id, title, owner_account_id→accounts, status (ACTIVE|ARCHIVED), created_at, updated_at)`.
- `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 — «общие» лабы), новый уникальный ключ `(number, course_id)` для лаб курса.
- `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).
@@ -175,6 +209,7 @@ SELECT student_id, group_id, 'ENROLL', enrolled_at FROM enrollments;
```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',
@@ -199,36 +234,61 @@ CREATE TABLE course_groups (
CREATE UNIQUE INDEX course_groups_active_uidx ON course_groups(course_id, group_id) WHERE removed_at IS NULL;
```
**V7 — лабораторные привязываем к курсу**
**V7 — лабораторные привязываем к курсу (nullable-фаза)**
```sql
-- «Курс по умолчанию» создаётся в сервисе (title='Общие лабораторные', owner=первый ADMIN)
-- Курс «Промышленное программирование на языке C» создаётся bootstrap-сервисом
-- после первого входа админа (owner = этот админ). В V7 только добавляем nullable-колонку.
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);
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)
UPDATE chapters SET audience = 'ALL' WHERE 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/{course}/{chapter}/{section}/{article}`;
старые ссылки на глобальные материалы сохраняются (совместимость).
Обновить URL-схему материалов: глобальные — `/learn/{chapter}/{section}/{article}`;
курсовые — `/learn/course/{course_no}/{chapter}/{section}/{article}`.
Старые ссылки на текущие материалы не поддерживаются (решение 2026-10-23).
**V9 — активы: наследуемая видимость**
Признак видимости актив наследует от статьи→раздела→главы (course/audience). Требуется проверка доступа в
маршруте выдачи активов (см. §6). Отдельных колонок достаточно опционально; минимум — корректная авторизация.
**V10 — курс C: завершение привязки лаб (post-bootstrap шаг)**
```sql
-- Выполняется после bootstrap-сервиса, когда все лабы получили course_id.
-- Это отдельный шаг/миграция: обычный Flyway-старт применяет миграции раньше
-- бизнес-логики и не может дождаться создания курса и назначения владельца.
ALTER TABLE labs ALTER COLUMN course_id SET NOT NULL;
```
### progress
Схема **не меняется**. Проверки доступа — рантайм (по `lab_id → course`).
Схема **в основном не меняется**. Проверки доступа — рантайм (по `lab_id → course`).
**V10 — профиль студента курса: автор комментария**
```sql
ALTER TABLE grade_comments ADD COLUMN author_account_id uuid;
ALTER TABLE lab_grades ADD COLUMN updated_by_account_id uuid;
CREATE INDEX lab_grades_lab_idx ON lab_grades (lab_id);
```
Индекс `grade_comments (student_id, lab_id)` уже существует (`grade_comments_grade_idx`).
Старые комментарии остаются без автора и отображаются как «автор не указан».
---
@@ -246,6 +306,43 @@ CREATE UNIQUE INDEX chapters_slug_course_uidx ON chapters (course_id, slug) WHER
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.
---
## 6. Материалы, публичный доступ и активы
@@ -340,14 +437,20 @@ CREATE UNIQUE INDEX chapters_slug_course_uidx ON chapters (course_id, slug) WHER
3. Одноимённые группы — уникальность по году набора; отображение с годом.
4. Академ vs групповой курс — правило перевода и автосоздание целевой группы.
5. Публичные `ALL`-материалы и **активы** — проверка видимости, иначе утечка/ломка.
6. `chapters.slug`: глобальный UNIQUE → частичные индексы (глобальные/курсовые) + новая URL-схема,
совместимость старых ссылок.
7. Миграция существующих лаб/материалов без курса (курс по умолчанию / `audience='ALL'`).
6. `chapters.slug`: глобальный UNIQUE → частичные индексы (глобальные/курсовые) + новая URL-схема;
старые ссылки на текущие материалы не поддерживаются.
7. Миграция существующих лаб/материалов в курс C; «общих» лаб нет.
8. Немедленный отзыв доступа (преподаватель/группа/публикация материала).
9. Аудит-лог: переводы, академ, повышение, назначения преподавателей, публикации, архив/удаление.
10. Производительность: индексы `course_id`/`group_id`, пагинация материалов и групп.
11. Бэкфилл `enrollment_year` для существующих групп — приблизительный, требует подтверждения админом.
12. Лабы: уникальность номера — в рамках курса (`(number, course_id)`), прежний ключ по семестру снимается.
13. Профиль студента курса: скоуп по курсу, все попытки, комментарии с автором,
аудит скачиваний, отсутствие N+1.
14. Статус защиты в ведомости: различать «готова» (нажал студент) и «кончилось время»
(timeout), с разным цветом и подсказкой.
15. Резервное копирование: обязательный backup перед деплоем/миграцией,
ежедневный backup по расписанию, retention, `verify` и restore на staging.
---
@@ -356,6 +459,7 @@ CREATE UNIQUE INDEX chapters_slug_course_uidx ON chapters (course_id, slug) WHER
0. Схема БД и миграции (аддитивно, с бэкфиллом) — по §4.
1. Группы: поля, разбор имени, «одна активная группа», импорт Excel, перемещение студента (админ).
2. Курсы + роль `TEACHER` + назначения (с историей) + скоуп-авторизация и тесты.
2a. Профиль студента курса: статистика по лабам, комментарии, все попытки, скачивание файлов.
3. Привязка лаб/материалов к курсу, аудитории материалов, публичные `ALL`-маршруты и активы, миграция данных.
4. Запись/снятие групп на курс.
5. Повышение курса: dry-run, журнал, идемпотентность, откат, выпуск.

View File

@@ -0,0 +1,292 @@
# 08. План реализации: группы, курсы, преподаватели
**Статус:** план согласован, реализация по этапам 0–6, один релиз.
**Основа:** [07-groups-courses.md](07-groups-courses.md).
**Дата решений:** 2026-10-23.
Документ фиксирует решения, принятые при разборе ТЗ, и порядок реализации.
При противоречии с `07-groups-courses.md` приоритет у раздела 1 этого плана;
само ТЗ приводится в соответствие на этапе 0.
---
## 1. Зафиксированные решения
| № | Вопрос | Решение |
|---|---|---|
| 1 | Лабораторные вне курса | Не нужны. Все лабораторные принадлежат курсу. Текущие лабы переносятся в курс «Промышленное программирование на языке C». Все текущие активные группы привязываются к этому курсу. `labs.course_id` сначала nullable, после переноса — `NOT NULL`. Перед миграцией проверяются дубли номеров. |
| 2 | Идентификатор курса в URL | 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 сохраняется как план; применение идемпотентно; откат — по элементам журнала с проверкой конфликтов. |
| 5 | Объём | Полный ТЗ, этапы 0–6, один релиз с внутренними merge gate. |
| 6 | Запуск повышения | 31 августа в 00:00 МСК автоматически строится dry-run. Применение — только вручную админом после проверки плана. При пропуске даты dry-run строится при следующем старте. |
| 7 | Профиль студента курса | Преподаватель своего курса может открыть профиль студента по клику на ФИО или отдельной кнопке: статистика по лабам, комментарии преподавателей, скачивание файлов любой попытки сдачи. |
| 8 | Резервное копирование | Обязательный backup перед каждым деплоем/миграцией, ежедневный backup по расписанию, retention, проверка целостности и restore на staging. Без успешного backup деплой не продолжается. |
---
## 2. Новое требование: профиль студента курса
### 2.1. Доступ
- **Преподаватель** — только если он активен в `course_teachers` для этого курса
(`removed_at IS NULL`), а студент входит в группу, привязанную к этому курсу
(`course_groups.removed_at IS NULL`).
- **Админ** — любой студент любого курса.
- **Студент** — свой профиль и только опубликованные оценки/комментарии
(опционально; в обязательный объём текущего требования не входит).
- **Аноним** — доступа нет.
- Проверка выполняется на сервисах, а не только в web-BFF. Чужой курс/студент
должен давать `403` или `404`, а не данные.
### 2.2. Состав профиля
Карточка студента:
- ФИО, № студента, группа, статус, год набора;
- контактные/служебные поля, если появятся позже.
Сводка по курсу:
- количество лабораторных курса;
- сколько сдано, сколько проверено;
- средний балл;
- количество защит и их статусы.
Таблица по каждой лабораторной курса:
- номер и название лабы;
- вариант и уровень (если назначены);
- количество попыток сдачи;
- дата последней попытки;
- оценка;
- статус публикации оценки;
- статус защиты;
- общий комментарий к оценке;
- количество комментариев.
Комментарии преподавателей по каждой лабе:
- общий комментарий оценки (`lab_grades.general_comment`);
- отдельные комментарии (`grade_comments`) с текстом, тоном, автором и датой;
- для старых комментариев без автора — «автор не указан»;
- новые комментарии сохраняют `author_account_id`.
Все попытки сдачи по каждой лабе:
- номер попытки, дата, список файлов;
- скачивание любой попытки, а не только последней;
- скачивание в рамках своего курса; чужой `submissionId` запрещён;
- для преподавателя — ZIP попытки и, при необходимости, отдельные файлы;
- для студента — только свои попытки.
### 2.3. API (предварительно)
- `GET /api/v1/courses/{courseId}/students/{studentId}/profile` — агрегированный
профиль студента курса.
- `GET /api/v1/courses/{courseId}/submissions/{submissionId}/download` — ZIP попытки
для преподавателя/админа.
- `GET /api/v1/courses/{courseId}/submissions/{submissionId}/files/{name}` —
отдельный файл попытки (если включаем отдельные файлы).
- `GET /api/v1/me/courses/{courseId}/profile` — собственный профиль студента
(только опубликованное), опционально; в обязательный объём не входит.
`courseId` в API — UUID. В браузерных URL используется `course_no`.
### 2.4. UI
- В ведомости группы ФИО студента становится ссылкой на профиль.
- В колонке действий появляется отдельная кнопка «Профиль».
- Страница преподавателя: `/teacher/courses/{courseNo}/students/{studentId}`.
- Админ открывает тот же профиль по `/admin/courses/{courseNo}/students/{studentId}`
или через общий маршрут с проверкой роли.
- Профиль не показывает чужие курсы и глобальные материалы.
- Для архивных/выпущенных студентов профиль открывается в режиме истории, если
студент был в курсе; иначе — `404`.
### 2.5. Данные и миграции
- `grade_comments` добавить `author_account_id uuid NULL`; индекс
`(student_id, lab_id)` уже существует.
- Для общего комментария оценки добавить `lab_grades.updated_by_account_id`
(автор последнего изменения) и индекс `lab_grades(lab_id)`.
- Все попытки уже хранятся в `submissions` (`student_id`, `lab_id`, `seq`);
отдельная таблица не нужна.
- Для производительности: один агрегирующий запрос/эндпоинт, пакетная выборка
комментариев, попыток, защит и публикаций по списку `lab_id`; без N+1.
- Аудит скачиваний: этап 6 (журнал `audit_events` или отдельная таблица).
### 2.6. Ограничения
- Преподаватель видит все лабы своего курса, включая черновики, но не может
видеть лабы других курсов.
- Студент видит только опубликованные оценки и комментарии.
- Скачивание файлов не даёт доступа к чужим сдачам.
- Профиль не заменяет ведомость: оценки по-прежнему выставляются в ведомости,
профиль — просмотр и скачивание.
---
## 3. Этапы
### Этап 0. Контракты и схема
- Обновить `07-groups-courses.md` и ADR под решения раздела 1.
- Описать API профиля студента и формат ответа.
- Миграции identity/academic/progress.
- Preflight: группы, не подходящие под шаблон имени; дубли номеров лаб;
материалы без курса; комментарии без автора.
- Проверить миграции на пустой БД и на копии текущей.
- Определить механизм `labs.course_id NOT NULL`: обычный Flyway-старт не может
дождаться bootstrap-курса. Варианты — seed курса C в миграции с временно
nullable владельцем либо отдельный post-bootstrap шаг. Решение — в ADR.
**Выход:** схема, контракты, отчёт о рисках; код бизнес-логики ещё не пишем.
### Этап 1. Identity
- Роли `ADMIN/TEACHER/STUDENT`.
- Поля групп и парсер имени.
- Статусы студентов и аккаунтов.
- `membership_history`.
- Импорт Excel v2 с построчным отчётом.
- Админ-UI групп и пользователей.
### Этап 2. Academic
- `courses`, `course_teachers`, `course_groups`.
- `course_no` и URL курса.
- `labs.course_id`, перенос лаб в курс C, копирование лаб.
- `chapters.course_id/audience`, перенос материалов в курс C.
- Частичные уникальные индексы slug.
- Активы: связь с главой/статьёй и наследуемая видимость.
### Этап 3. Доступ и профиль (backend)
- `TEACHER` в JWT и security-конфигах.
- Внутренние access-эндпоинты academic/identity.
- Проверки курса, группы, студента и публикации на сервисах.
- Профиль студента курса: агрегирующий endpoint, все попытки, комментарии,
скачивание файлов.
- Negative-тесты: чужой курс, чужой студент, чужая сдача, аноним.
### Этап 4. Lifecycle
- Повышение: dry-run, журнал, применение, откат, выпуск.
- Академический отпуск и возврат.
- Отчисление, выпуск, архив.
- Усиленное удаление и аудит.
### Этап 5. UX
- Кабинет преподавателя.
- Статус защиты в ведомости: «готова» / «кончилось время» (timeout),
с разным цветом и подсказкой; сейчас в матрице всегда показывается «готова».
- Страница профиля студента: ссылка с ФИО и отдельная кнопка.
- Студенческие страницы курсов.
- Админ-страницы курсов, повышения, отпусков, архива, импорта и копирования лаб.
- Публичные глобальные материалы `ALL`.
### Этап 6. Hardening
- Полная security-матрица.
- Upgrade-тест миграций.
- Индексы и пагинация.
- Аудит скачиваний и чувствительных действий.
- Проверка backup: создание, retention, `verify`, restore на staging.
- Документация, deploy/runbook, smoke.
---
## 4. Ориентировочные миграции
Номера уточняются при реализации, чтобы не конфликтовать с текущими максимумами
(identity V3, academic V5, progress V9).
| Сервис | Миграции | Содержание |
|---|---|---|
| identity | V4–V9 | роли, поля групп, статусы студентов, `membership_history`, `promotion_runs/items`, `accounts.status` |
| academic | V6–V9 + post-bootstrap V10 | курсы, преподаватели, группы курса, `labs.course_id` (nullable), материалы курса/аудитории, частичные индексы, активы; `SET NOT NULL` — отдельный шаг после bootstrap |
| progress | V10 (+ V11 при необходимости аудита) | `grade_comments.author_account_id`, `lab_grades.updated_by_account_id`, индекс `lab_grades(lab_id)`; аудит скачиваний — этап 6 |
---
## 5. Агенты
| Агент | Зона | Границы |
|---|---|---|
| `lms-orchestrator` | контракты, ADR, merge, review | не пишет массово код |
| `lms-db` | миграции и тесты миграций | только `db/migration/**` и фикстуры |
| `lms-identity` | identity-service | только identity и его тесты |
| `lms-academic` | academic-service | только academic и его тесты |
| `lms-progress` | progress-service, профиль, сдачи | только progress и его тесты |
| `lms-web` | web, UI профиля, кабинеты | только `web/**` |
| `lms-security` | `libs/security-jwt`, gateway, security-review | без бизнес-логики |
| `lms-qa` | матрица ролей, upgrade, smoke | production-код не правит |
| `lms-docs` | docs, deploy/runbook | только документация |
---
## 6. Skills
- `lms-context` — инварианты и карта модулей.
- `lms-migrations` — Flyway, двухфазные миграции, preflight, legacy-БД.
- `lms-authz` — ролевая матрица, internal access, negative-тесты.
- `lms-identity-domain` — группы, статусы, история, импорт, повышение.
- `lms-academic-domain` — курсы, лабы, материалы, аудитории, активы.
- `lms-web-ui` — Thymeleaf/Bootstrap/JS, формы, навигация.
- `lms-testing` — Testcontainers, MockMvc с JWT, фикстуры.
- `lms-release` — lint/detekt/test, bootJar, `run.sh`, smoke.
---
## 7. Контроль качества
1. Миграции на пустой БД и на копии текущей; preflight-отчёт.
2. Security-матрица: аноним, студент свой/чужой, преподаватель свой/чужой,
админ, архивный/выпущенный.
3. Профиль: чужой курс/студент/сдача → `403/404`; все попытки скачиваются;
комментарии с автором; нет N+1.
4. `./gradlew ktlintCheck detekt test` после каждого этапа.
5. UI-smoke для четырёх ролей, старые и новые URL, публичные/приватные активы.
6. Аудит скачиваний и чувствительных действий.
7. Backup: обязательный перед деплоем, ежедневный, с проверкой restore.
8. Документация и runbook миграций.
---
## 8. Риски и открытые вопросы
- **Большой объём одного релиза.** Снижаем контрактами, merge gate и независимым QA.
- **Межсервисная авторизация.** Самый сложный участок; ADR до кода.
- **Перенос данных.** Нужны preflight и копия БД; группы с нестандартными именами
и дубли номеров лаб могут заблокировать `NOT NULL`.
- **Активы.** Сейчас публичны и не связаны со статьями; нужна миграция связей.
- **JWT 30 дней.** Средний вариант блокировки оставляет остаточный риск для
прямого API; фиксируем в документации.
- **Комментарии без автора.** Старые записи останутся с «автор не указан».
- **Архивные студенты.** Открывать ли профиль после отвязки группы — уточнить
на этапе 3; по умолчанию история курса доступна, если студент был в курсе.
---
## 9. Статус реализации
- **Этап 0 — выполнен.** Схема, contract API профиля, preflight, ADR,
проверка на копии БД. Отчёт: [10-stage0-report.md](10-stage0-report.md).
- **Этап 1 — выполнен.** Роли, группы-когорты, статусы, история, импорт v2,
админ-UI. Отчёт: [11-stage1-report.md](11-stage1-report.md).
- **Этап 2 — следующий.** Курсы, преподаватели, привязка групп, лабораторные
и материалы курса C, активы, копирование лаб.
- Этапы 3–6 — по плану.
## 10. Definition of Done
- Все решения раздела 1 реализованы.
- Текущие лабы и материалы работают в курсе C.
- Преподаватель видит только свои курсы и профили своих студентов.
- Профиль показывает статистику, комментарии и все попытки; файлы скачиваются.
- Повышение, академотпуск, выпуск и архив работают по ТЗ.
- Security-матрица и миграционный upgrade-тест зелёные.
- Backup перед деплоем обязателен; restore проверен на staging.
- Документация, deploy/runbook и smoke обновлены.

View File

@@ -0,0 +1,285 @@
# 09. Контракт API: профиль студента курса
**Статус:** контракт этапа 0, реализация — этапы 3 и 5.
**Связанные документы:** [07-groups-courses.md](07-groups-courses.md),
[08-groups-courses-plan.md](08-groups-courses-plan.md),
[ADR 0001](adr/0001-groups-courses-foundation.md).
## 1. Назначение
Преподаватель своего курса открывает профиль студента и видит:
- статистику по лабораторным курса;
- комментарии преподавателей по каждой лабе;
- все попытки сдачи и может скачать файлы любой попытки.
Профиль ограничен одним курсом. Он не заменяет ведомость и не даёт доступа
к другим курсам или чужим сдачам.
## 2. Авторизация и скоуп
| Роль | Доступ |
|---|---|
| `ADMIN` | любой студент любого курса |
| `TEACHER` | только активный преподаватель курса (`course_teachers.removed_at IS NULL`) и только студент группы, привязанной к курсу (`course_groups.removed_at IS NULL`) |
| `STUDENT` | только свой профиль и только опубликованные оценки/комментарии (опционально) |
| Аноним | нет |
Проверки выполняются на сервисах:
1. JWT валиден, роль допустима.
2. Курс существует и не архивирован (для преподавателя).
3. Для `TEACHER` есть активная запись в `course_teachers`.
4. Студент существует, его аккаунт не архивный.
5. Группа студента привязана к курсу (`course_groups.removed_at IS NULL`).
6. Для скачивания сдача принадлежит этому студенту и лабе этого курса.
Чужой курс, студент или сдача → `403`/`404` без раскрытия данных.
## 3. Основной endpoint
```http
GET /api/v1/courses/{courseId}/students/{studentId}/profile
Authorization: Bearer <JWT>
```
Параметры:
- `courseId` — UUID курса;
- `studentId` — UUID аккаунта студента (`students.account_id`).
Ответ `200 application/json`:
```json
{
"course": {
"id": "3f2a7c1e-8b4d-4a6f-9c2e-1d5b7a9f0c3e",
"courseNo": 1,
"title": "Промышленное программирование на языке C"
},
"student": {
"id": "7a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"studentNo": "123456",
"fullName": "Иванов Иван Иванович",
"groupId": "11111111-2222-3333-4444-555555555555",
"groupName": "ПИ-21БО",
"status": "ACTIVE",
"enrollmentYear": 2025
},
"summary": {
"labsTotal": 8,
"submitted": 6,
"graded": 5,
"averageScore": 4.2,
"defensesTotal": 5,
"defensesReady": 3
},
"labs": [
{
"labId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"number": 1,
"title": "Введение в C",
"deadlineAt": "2026-10-01T20:59:00Z",
"variant": {
"id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
"number": "3",
"title": "Вариант 3"
},
"level": {
"id": "cccccccc-dddd-eeee-ffff-000000000000",
"code": "base",
"title": "Базовый"
},
"attemptsCount": 2,
"lastSubmissionAt": "2026-09-28T18:12:00Z",
"grade": {
"score": 4.5,
"published": true,
"generalComment": "Хорошая работа, но есть замечания по стилю.",
"updatedBy": {
"id": "dddddddd-eeee-ffff-0000-111111111111",
"name": "Петров Пётр Петрович"
}
},
"comments": [
{
"id": "eeeeeeee-ffff-0000-1111-222222222222",
"text": "Разделите функцию на части.",
"tone": "neutral",
"author": {
"id": "dddddddd-eeee-ffff-0000-111111111111",
"name": "Петров Пётр Петрович"
},
"createdAt": "2026-09-29T09:30:00Z"
}
],
"defense": {
"sessionId": "ffffffff-0000-1111-2222-333333333333",
"status": "ready",
"readyReason": "timeout",
"code": null,
"assignmentMd": "Дополнительное задание..."
},
"submissions": [
{
"id": "00000000-1111-2222-3333-444444444444",
"seq": 1,
"createdAt": "2026-09-27T16:00:00Z",
"files": ["main.c", "Makefile"],
"downloadUrl": "/api/v1/courses/3f2a7c1e-8b4d-4a6f-9c2e-1d5b7a9f0c3e/submissions/00000000-1111-2222-3333-444444444444/download"
},
{
"id": "00000000-5555-6666-7777-888888888888",
"seq": 2,
"createdAt": "2026-09-28T18:12:00Z",
"files": ["main.c", "Makefile", "README.md"],
"downloadUrl": "/api/v1/courses/3f2a7c1e-8b4d-4a6f-9c2e-1d5b7a9f0c3e/submissions/00000000-5555-6666-7777-888888888888/download"
}
]
}
]
}
```
Правила:
- `labs` — все лабы курса для преподавателя/админа; для студента — только
опубликованные.
- `grade` и `comments` для студента отдаются только если оценка опубликована
(`grade_publications` / `grade_publication_students`).
- `comments` включает общий комментарий? Нет: общий комментарий — поле
`grade.generalComment`; отдельные комментарии — массив `comments`.
- `author` может быть `null`, если автор не сохранён (старые данные).
- `averageScore` — среднее по выставленным оценкам, `null` если оценок нет.
- `submissions` отсортированы по `seq`; файлы — имена без пути.
- `downloadUrl` относительный; web-BFF добавляет авторизацию.
## 4. Скачивание попытки
```http
GET /api/v1/courses/{courseId}/submissions/{submissionId}/download
Authorization: Bearer <JWT>
```
- Возвращает ZIP со всеми файлами попытки.
- Проверки: сдача принадлежит студенту и лабе курса; преподаватель активен в
курсе; админ — всегда.
- Имя файла: `submission-{labId-prefix}-try{seq}.zip`.
- Ошибки: `403` — чужая сдача/курс, `404` — сдача не найдена.
Опционально:
```http
GET /api/v1/courses/{courseId}/submissions/{submissionId}/files/{filename}
```
- Отдаёт один файл.
- Имя проверяется на path traversal; файл должен быть в каталоге попытки.
## 5. Внутренние эндпоинты
Не маршрутизируются gateway, защищены `X-Internal-Token` и пользовательским JWT
там, где он нужен.
### identity
```http
GET /api/v1/internal/students/{studentId}/context
```
```json
{
"accountId": "7a1b2c3d-...",
"role": "STUDENT",
"accountStatus": "ACTIVE",
"studentStatus": "ACTIVE",
"studentNo": "123456",
"fullName": "Иванов Иван Иванович",
"groupId": "11111111-...",
"groupName": "ПИ-21БО",
"enrollmentYear": 2025
}
```
### academic
```http
GET /api/v1/internal/courses/{courseId}/access?accountId={accountId}&groupId={groupId}
```
```json
{
"courseId": "3f2a7c1e-...",
"courseStatus": "ACTIVE",
"teacherActive": true,
"groupAttached": true
}
```
```http
GET /api/v1/internal/courses/{courseId}/labs
```
Возвращает лабы курса с `id`, `number`, `title`, `deadlineAt`, `status`,
`pointsMax`, `variant/level` при необходимости.
## 6. Ошибки
| Код | Когда |
|---|---|
| `401` | нет/просрочен JWT |
| `403` | роль не подходит, чужой курс, преподаватель не активен, студент не в группе курса |
| `404` | курс, студент, лаба или сдача не найдены |
| `409` | профиль недоступен из-за конфликта данных (например, студент без группы) |
Тело ошибки — общий `ApiError` проекта.
## 7. Производительность
- Один агрегирующий запрос на профиль, без N+1.
- Пакетная выборка по списку `labId`:
- оценки — `lab_grades` по `student_id` и `lab_id IN (...)`;
- комментарии — `grade_comments` по `student_id` и `lab_id IN (...)`;
- сдачи — `submissions` по `student_id` и `lab_id IN (...)`;
- защиты — `defense_sessions` по `student_id` и `lab_id IN (...)`;
- публикации — `grade_publications`/`grade_publication_students` по группе и лабам.
- Список лаб курса кэшируется на время запроса.
- Для больших курсов — пагинация попыток (по умолчанию все, но с лимитом).
- Индексы: `submissions(student_id, lab_id, seq)` уже уникален;
`grade_comments(student_id, lab_id)` уже есть; `lab_grades(lab_id)` добавлен;
`defense_sessions(student_id, lab_id)` уникален.
## 8. UI
- В ведомости группы ФИО — ссылка на профиль.
- В колонке действий — кнопка «Профиль».
- Маршрут преподавателя: `/teacher/courses/{courseNo}/students/{studentId}`.
- Админ: `/admin/courses/{courseNo}/students/{studentId}` или общий маршрут
с проверкой роли.
- В профиле:
- карточка студента;
- сводка;
- таблица лаб;
- блок комментариев;
- список попыток с кнопками скачивания.
- Статус защиты: «готова» / «кончилось время» (timeout).
## 9. Тесты
- `ADMIN` видит любого студента.
- `TEACHER` своего курса — `200`; чужого курса — `403`.
- `TEACHER` чужого студента — `403`.
- `STUDENT` свой профиль — `200`; чужой — `403`.
- Аноним — `401`.
- Скачивание своей попытки — `200`; чужой — `403`.
- Студент не видит неопубликованные оценки/комментарии.
- Все попытки возвращаются, а не только последняя.
- Комментарии содержат автора или `null`.
- Запрос не делает N+1 (проверка по числу SQL-запросов в интеграционном тесте).
## 10. Открытые вопросы
- Показывать ли профиль для архивных/отвязанных студентов (история или `404`).
- Нужен ли студенческий просмотр своего профиля в первом релизе.
- Формат аудита скачиваний: общий `audit_events` или отдельная таблица.
- Ограничение количества попыток в ответе и пагинация.

91
docs/10-stage0-report.md Normal file
View File

@@ -0,0 +1,91 @@
# 10. Отчёт этапа 0: схема, контракты, preflight
**Дата:** 2026-10-23.
**Статус:** этап 0 выполнен на локальной копии БД; перед production нужен
повторный preflight на свежей копии.
## 1. Что сделано
- ADR с ключевыми решениями: [adr/0001-groups-courses-foundation.md](adr/0001-groups-courses-foundation.md).
- Контракт API профиля студента: [09-teacher-student-profile.md](09-teacher-student-profile.md).
- Миграции:
- identity `V4–V9`: роли, группы-когорты, статусы студентов, повышение,
история членства, статусы аккаунтов;
- academic `V6–V9`: курсы, преподаватели, группы курса, `labs.course_id`,
материалы курса/аудитории, индексы активов;
- progress `V10`: автор комментария и последнего изменения оценки.
- Preflight-скрипты:
- `deploy/preflight/groups-courses.sql` — до миграций;
- `deploy/preflight/groups-courses-post.sql` — после миграций/bootstrap.
- UI: статус защиты в ведомости различает «готова» и «кончилось время».
- Резервное копирование:
- `deploy/backup.sh` — БД + решения + материалы, checksums, retention,
`list`, `verify`, `restore`;
- `deploy/deploy.sh up` делает обязательный backup перед обновлением;
- systemd timer для ежедневного backup;
- инструкция `deploy/BACKUP.md`.
## 2. Проверка на пустой БД
Все миграции identity, academic и progress применены последовательно на чистой
БД PostgreSQL 18. Ошибок нет. Тесты сервисов на Testcontainers проходят.
## 3. Проверка на копии локальной БД
Скопирована локальная БД `lms` (41 аккаунт, 40 студентов, 2 группы, 1 лаба,
3 главы, 16 разделов, 35 статей, 6 комментариев).
Preflight до миграций:
- групп, не подходящих под шаблон, нет;
- конфликтующих кортежей когорт нет;
- дублей номеров лабораторных нет;
- дублей slug глав нет;
- студентов без группы нет.
Миграции применены успешно.
После миграций:
- `labs_without_course = 1` — ожидаемо до bootstrap;
- `chapters_without_course = 3` — ожидаемо до bootstrap;
- `courses = 0` — ожидаемо до bootstrap;
- `comments_without_author = 6` — ожидаемо для старых комментариев;
- группы разобраны полностью, `enrollment_year` заполнен.
## 4. Симуляция bootstrap
На копии вручную выполнено то, что будет делать bootstrap-сервис:
1. Создан курс C с владельцем-админом, `course_no = 1`.
2. Админ назначен преподавателем курса.
3. Обе активные группы привязаны к курсу.
4. Все лабы и главы получили `course_id`.
5. Выполнено `ALTER TABLE labs ALTER COLUMN course_id SET NOT NULL`.
Postflight:
- `labs_without_course = 0`;
- `chapters_without_course = 0`;
- курс C ровно один;
- `comments_without_author = 6` — старые данные, ожидаемо;
- группы без полного разбора отсутствуют.
## 5. Тесты и линтеры
- `./gradlew :services:identity-service:test :services:academic-service:test :services:progress-service:test :web:test` — успешно.
- `./gradlew ktlintCheck detekt` — успешно.
- `node --check web/src/main/resources/static/js/admin-group.js` — успешно.
- `bash -n deploy/backup.sh deploy/deploy.sh` — успешно.
- `./deploy/backup.sh backup` на локальной БД — успешно; `verify` проверил
`db.dump`, `submissions.tar.gz`, `assets.tar.gz` по SHA256.
- `git diff --check` — успешно.
## 6. Что осталось на этап 2
- Реализовать bootstrap-сервис курса C (создание курса, привязка групп,
назначение админа, перенос лаб/глав).
- Выбрать механизм post-bootstrap `SET NOT NULL` (CLI, admin endpoint или
отдельная Flyway-локация).
- Связать существующие активы со статьями при миграции контента.
- Повторить preflight на свежей копии production-БД перед деплоем.

View File

@@ -15,6 +15,11 @@ Preview Mermaid Support) и в IntelliJ IDEA (плагин Mermaid).
| [05-states.md](05-states.md) | Диаграммы состояний: защита, публикация оценок, статус ЛР, публикация материалов |
| [06-authoring.md](06-authoring.md) | Работа с учебными материалами: структура, markdown-редактор, импорт zip |
| [07-groups-courses.md](07-groups-courses.md) | ТЗ: группы/потоки, курсы и преподаватели, повышение курса, академический отпуск, интеграция с «Контингент» |
| [08-groups-courses-plan.md](08-groups-courses-plan.md) | План реализации ТЗ 07: решения, этапы 0–6, профиль студента курса, агенты и skills |
| [09-teacher-student-profile.md](09-teacher-student-profile.md) | Контракт API профиля студента курса для преподавателя |
| [10-stage0-report.md](10-stage0-report.md) | Отчёт этапа 0: миграции, preflight и проверка на копии БД |
| [11-stage1-report.md](11-stage1-report.md) | Отчёт этапа 1: роли, группы-когорты, статусы, история, импорт v2 |
| [ADR 0001](adr/0001-groups-courses-foundation.md) | Ключевые архитектурные решения: курс C, миграции, доступ, статусы, повышение |
## Кратко о системе

View File

@@ -0,0 +1,150 @@
# ADR 0001. Фундамент групп, курсов и доступа
**Статус:** принято.
**Дата:** 2026-10-23.
**Контекст:** реализация [ТЗ 07](../07-groups-courses.md) и
[плана 08](../08-groups-courses-plan.md).
## Контекст
Сейчас в LMS нет сущности курса-дисциплины, ролей преподавателя, статусов
студентов и истории членства. Лабораторные привязаны к семестру, материалы —
к глобальному дереву глав. Доступ различает только `ADMIN` и «всё остальное»;
проверки в основном находятся в web-BFF.
Нужно перейти к модели:
- курс-дисциплина с преподавателями и группами;
- группы-когорты с потоком, курсом обучения, номером, степенью и годом набора;
- студенты со статусами и историей;
- лабораторные и материалы, принадлежащие курсу;
- проверки доступа на сервисах;
- профиль студента курса для преподавателя.
## Решения
### 1. Курс C и `course_no`
- Текущий единственный курс — «Промышленное программирование на языке C».
- UUID остаётся первичным ключом курса.
- Для URL добавляется `courses.course_no`:
`BIGINT GENERATED ALWAYS AS IDENTITY UNIQUE NOT NULL`.
- Курсовые материалы открываются по
`/learn/course/{course_no}/{chapter}/{section}/{article}`.
- `course_no` не является секретом; доступ проверяется отдельно.
- Старые ссылки на текущие материалы не поддерживаются: они не публиковались.
### 2. Все лабораторные и материалы принадлежат курсу
- «Общих» лабораторных нет.
- Все текущие лабы, главы, разделы, статьи и активы переносятся в курс C.
- `labs.course_id` вводится как nullable на время миграции, затем становится
`NOT NULL`.
- `chapters.course_id` остаётся nullable, потому что глобальные материалы
сохраняются как отдельный сценарий. `audience` применяется только к глобальным
главам (`course_id IS NULL`).
- `sections` и `articles` наследуют видимость от главы.
- Активы наследуют видимость от статьи → раздела → главы.
### 3. Двухфазная миграция и bootstrap
Обычный Flyway-старт применяет миграции до бизнес-логики, а курс C должен
принадлежать реальному админу. Поэтому:
1. Миграции добавляют nullable-поля и новые таблицы.
2. После первого входа админа bootstrap-сервис:
- создаёт курс C с `owner_account_id` админа;
- привязывает к нему все текущие активные группы;
- проставляет `labs.course_id` и `chapters.course_id`;
- проверяет дубли `(number, course_id)` и сообщает о конфликтах.
3. После успешного bootstrap отдельным шагом применяется
`ALTER TABLE labs ALTER COLUMN course_id SET NOT NULL`.
`SET NOT NULL` не входит в обычную цепочку Flyway: иначе миграция выполнится
раньше bootstrap и упадёт на существующих лабах. Точный механизм шага
(CLI, admin endpoint или отдельная Flyway-локация) фиксируется на этапе 2.
### 4. Проверки доступа на сервисах
- JWT содержит только `sub`, `role`, `login`, `name`; списки курсов и групп
в токен не кладутся.
- Каждый сервис проверяет доступ по своим данным и данным источника истины:
- identity — аккаунт, студент, группа, статус;
- academic — курс, преподаватель, группа курса, лабораторная, материал;
- progress — оценки, сдачи, защиты, публикации.
- Для межсервисных проверок используются внутренние эндпоинты
`/api/v1/internal/**`:
- identity: контекст студента (группа, статус, год набора);
- academic: доступ к курсу (активный преподаватель, привязанная группа,
статус курса), список лаб курса.
- Внутренние эндпоинты не маршрутизируются gateway и защищены отдельным
сервисным токеном (`X-Internal-Token`), а не только пользовательским JWT.
- Кросс-схемные SQL-запросы и внешние ключи между `identity`, `academic` и
`progress` не добавляются.
- Отзыв доступа к курсу/группе/публикации действует сразу, потому что
проверяется на каждом запросе по текущим данным.
### 5. Статусы и вход
- `accounts.status`: `ACTIVE | ARCHIVED`.
- `students.status`: `ACTIVE | ACADEMIC_LEAVE | EXPELLED | GRADUATED | ARCHIVED`.
- Вход запрещён для `EXPELLED`, `GRADUATED`, `ARCHIVED` и архивных аккаунтов.
- `ACADEMIC_LEAVE` — вход в режиме только чтения.
- Web перепроверяет статус с кэшем 30–60 секунд; сервисы проверяют статус
студента и группы при доступе к курсу.
- JWT остаётся 30-дневным; остаточный риск прямого API фиксируется в
документации. Для браузера токен не доступен.
### 6. Повышение курса
- `promotion_runs` — шапка запуска.
- `promotion_run_items` — по-элементный план/результат для групп и студентов.
- `membership_history` — история студента; ссылается на запуск.
- 31 августа в 00:00 МСК автоматически создаётся `DRY_RUN`.
- Применение — только вручную админом после проверки плана.
- Один успешный `APPLY` на год; откат — по элементам журнала с проверкой
конфликтов.
### 7. Профиль студента курса
- Преподаватель активного курса открывает профиль студента по клику на ФИО
или отдельной кнопке.
- Профиль агрегирует:
- статистику по лабам курса;
- комментарии преподавателей (общий и отдельные) с автором и датой;
- все попытки сдачи и скачивание файлов любой попытки.
- Доступ проверяется на сервисах: преподаватель — только свой курс и студенты
его групп; админ — любой; студент — только свой профиль (опционально).
- Контракт API: [09-teacher-student-profile.md](../09-teacher-student-profile.md).
### 8. Резервное копирование
- Backup обязателен перед каждым деплоем/миграцией; при ошибке backup деплой
прерывается.
- Backup включает БД (`pg_dump --format=custom`), volume решений и volume
материалов, `SHA256SUMS` и manifest.
- Ежедневный backup по systemd timer; retention по умолчанию 14 дней.
- Восстановление — только с подтверждением `RESTORE`; после restore проверяется
health web.
- Backup на том же сервере не считается достаточным: нужна внешняя копия и
периодическая проверка restore на staging.
- Инструкция: [deploy/BACKUP.md](../../deploy/BACKUP.md).
## Последствия
- Схема становится шире, но миграции аддитивные и не ломают старый код.
- Появляется отдельный bootstrap-шаг; его нельзя пропускать при развёртывании.
- Нужны внутренние эндпоинты и сервисный токен.
- Нужно обновить security-конфиги, gateway и web-интерфейс.
- Старые ссылки на материалы перестанут работать; это осознанное решение.
- До включения `NOT NULL` в `labs.course_id` возможны лабы без курса; preflight
и bootstrap-отчёт обязательны.
- Появляется обязательный backup-шаг; без него деплой не выполняется.
## Открытые вопросы
- Механизм post-bootstrap `SET NOT NULL`: CLI, admin endpoint или отдельная
Flyway-локация. Решение — этап 2.
- Формат аудита скачиваний профиля: общий `audit_events` или отдельная таблица.
- Поведение профиля для студентов, отвязанных от курса/архивных: история или
`404`. Решение — этап 3.