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

37 KiB
Raw Blame History

07. Группы, курсы и преподаватели, повышение, интеграция (ТЗ)

Статус: проект согласован, реализация ведётся по плану 08-groups-courses-plan.md. Документ описывает целевую модель, схему преобразований БД, правила и граничные случаи.

0. Решения, принятые при планировании (2026-10-23)

При противоречии с разделами 1–14 приоритет у этого раздела и плана 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)

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 — роли

ALTER TABLE accounts DROP CONSTRAINT accounts_role_check;
ALTER TABLE accounts ADD CONSTRAINT accounts_role_check CHECK (role IN ('ADMIN','TEACHER','STUDENT'));

V5 — группы: поля, бэкфилл, уникальность

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 — студенты: статус и год набора

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 — история членства

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 — курс (пересоздание)

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-фаза)

-- Курс «Промышленное программирование на языке 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 — материалы: курс и аудитория, слаг

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). Отдельных колонок достаточно опционально; минимум — корректная авторизация.

V10 — уникальное название курса (реальный файл V10__course_title_unique.sql)

-- Защита от дублей курса при конкурентном bootstrap и повторном создании.
CREATE UNIQUE INDEX courses_title_lower_uidx ON courses (lower(title));

Сервис дополнительно проверяет название без учёта регистра и возвращает 409 на дубликат; индекс закрывает гонку между проверкой и вставкой.

Post-bootstrap шаг academic (вне Flyway) — курс C: завершение привязки лаб

-- Выполняется сервисом CourseBootstrapService после переноса данных, когда все
-- лабы получили course_id. Обычный Flyway-старт применяет миграции раньше
-- бизнес-логики и не может дождаться создания курса и назначения владельца.
ALTER TABLE labs ALTER COLUMN course_id SET NOT NULL;

progress

Схема в основном не меняется. Проверки доступа — рантайм (по lab_id → course).

V13 — профиль студента курса: автор комментария

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.

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. Аудит-лог: переводы, академ, повышение, назначения преподавателей, публикации, архив/удаление.
  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. Этапы

  1. Схема БД и миграции (аддитивно, с бэкфиллом) — по §4.
  2. Группы: поля, разбор имени, «одна активная группа», импорт Excel, перемещение студента (админ).
  3. Курсы + роль TEACHER + назначения (с историей) + скоуп-авторизация и тесты. 2a. Профиль студента курса: статистика по лабам, комментарии, все попытки, скачивание файлов.
  4. Привязка лаб/материалов к курсу, аудитории материалов, публичные ALL-маршруты и активы, миграция данных.
  5. Запись/снятие групп на курс.
  6. Повышение курса: dry-run, журнал, идемпотентность, откат, выпуск.
  7. Академический отпуск/возврат, отчисление, выпуск, архив; усиленное удаление.
  8. (Позже) сервис-адаптер «Контингент»/next.uniyar.ac.ru.

Открытых вопросов нет: все решения зафиксированы в §1.