27 KiB
07. Группы, курсы и преподаватели, повышение, интеграция (ТЗ)
Статус: проект согласован, к реализации не приступаем до отдельного сигнала. Документ описывает целевую модель, схему преобразований БД, правила и граничные случаи.
1. Зафиксированные решения
- Сущность курс живёт в
academic-service; владелец — админ, преподавателей на курс назначает/снимает админ, состав меняется во времени; у преподавателя может быть несколько курсов. - Группа хранит
stream,course,number,degree,enrollment_year. Имя группы — производное (пересчитывается при повышении), не уникально; уникальность — по(stream, degree, number, course, enrollment_year). Одноимённые группы разных наборов сосуществуют и различаются годом набора. - Повышение курса — инкремент
courseу самой группы (без пересоздания и копирования студентов). Лимиты: БО 1–4, МО 1–2; при достижении предела — выпуск. - Академический отпуск: студент не следует за группой. Переводится в группу
(stream, number, degree, course = прошлый курс)сenrollment_year = текущий_год − (прошлый_курс − 1); если такой группы нет — создаётся (кейс «был на 1 курсе и ушёл в академ» → группа 1 курса с годом набора на 1 больше). - Материалы вне курса имеют аудиторию:
ALL(видят все, включая неавторизованных),STUDENTS,TEACHERS. Глобальные материалы публикует только админ. - Лабораторные можно копировать в другой курс; переносить между курсами нельзя.
- Выпуск: БО-4 / МО-2 → группа и студенты
GRADUATED, вход закрыт, группа замораживается. - Для пользователя доступны обе операции: архив и полное удаление; удаление с историей — с усиленным подтверждением.
- Импорт Excel — только админ. Ключ слияния — № студента (
student_no, уникален); ФИО не является ключом (дубликаты допустимы, в UI различаем по №). - Внешние идентификаторы под «Контингент»/
next.uniyar.ac.ruсейчас не заводим — добавим миграцией; интеграция — отдельный сервис, позже. - Выгрузки используют текущее имя группы + год набора:
ПИ-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)
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 — роли
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(),
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 — лабораторные привязываем к курсу
-- «Курс по умолчанию» создаётся в сервисе (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 — материалы: курс и аудитория, слаг
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 |
Обязательные требования:
- Скоуп проверяется на сервисах, а не только в web-BFF (иначе прямой вызов чужого
labId/articleId/groupId). - Доступ отзывается немедленно (не хранить список курсов/групп в JWT).
- Файлы: преподаватель — только сдачи своих курсов; студент — только свои; активы — по видимости материала.
6. Материалы, публичный доступ и активы
- Аудитории:
ALL(в т.ч. неавторизованные),STUDENTS,TEACHERS; глобальные публикует только админ. - Нужны публичные маршруты для
ALLбез аутентификации.STUDENTS/TEACHERS/курсовые материалы анонимно недоступны ни при каких условиях. - Активы (риск): материалы ссылаются на файлы (
/api/v1/assets/**). Для публичных глав активы должны быть доступны, для приватных — нет. Решение: видимость актива наследуется от главы, и маршрут выдачи активов обязан это проверять (либо отдельный публичный префикс дляALL). Без этого публичные материалы будут ломаться, а приватные — утекать. - Поисковая индексация: для
ALL— по умолчанию разрешена; для остального — закрыть (robots/noindex).
7. Повышение курса (31 августа, МСК)
Псевдокод и правила:
- Запуск только ADMIN, сначала dry-run (план + подтверждение).
- Обработка детерминированная (для аудита — от старших курсов к младшим). Благодаря уникальности
(…, course, enrollment_year)коллизии имён при повышении не возникают (одноимённые группы разных наборов сосуществуют легально). - Группы со
status='ACTIVE'иcourse < лимит(degree)→course += 1(имя пересчитывается). - Группы на пределе (БО-4, МО-2) →
status='GRADUATED', имя фиксируется, из будущих прогонов исключаются. - Студенты
ACADEMIC_LEAVE— по правилу §1.4 (перевод в группу курсом ниже; при отсутствии — создать). EXPELLED,ARCHIVED,GRADUATED— не трогаем.- Идемпотентность: таблица
promotion_runs(year, mode, actor_account_id, started_at, finished_at, result); повторный запуск за год не меняет данные. - Догон пропущенного запуска (сервер лежал 31.08) — кнопкой, с отчётом.
- Откат по журналу (при ручных правках — требовать подтверждение).
- Отчёт: список изменений и «заблокированных» кейсов.
Граничные случаи: одноимённые группы разных наборов; повышение при уже импортированном новом наборе (допустимо, различаем годом); группы без студентов; ручные правки курса/номера до повышения (валидация и предупреждение); академ на 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. Граничные случаи и риски (чек-лист реализации)
- Скоуп-авторизация на сервисах + тесты «чужой ресурс» (все роли × эндпоинты).
- Порядок и идемпотентность повышения; dry-run; откат; МСК; догон пропущенного запуска.
- Одноимённые группы — уникальность по году набора; отображение с годом.
- Академ vs групповой курс — правило перевода и автосоздание целевой группы.
- Публичные
ALL-материалы и активы — проверка видимости, иначе утечка/ломка. chapters.slug: глобальный UNIQUE → частичные индексы (глобальные/курсовые) + новая URL-схема, совместимость старых ссылок.- Миграция существующих лаб/материалов без курса (курс по умолчанию /
audience='ALL'). - Немедленный отзыв доступа (преподаватель/группа/публикация материала).
- Аудит-лог: переводы, академ, повышение, назначения преподавателей, публикации, архив/удаление.
- Производительность: индексы
course_id/group_id, пагинация материалов и групп. - Бэкфилл
enrollment_yearдля существующих групп — приблизительный, требует подтверждения админом. - Лабы: уникальность номера — в рамках курса (
(number, course_id)), прежний ключ по семестру снимается.
14. Этапы
- Схема БД и миграции (аддитивно, с бэкфиллом) — по §4.
- Группы: поля, разбор имени, «одна активная группа», импорт Excel, перемещение студента (админ).
- Курсы + роль
TEACHER+ назначения (с историей) + скоуп-авторизация и тесты. - Привязка лаб/материалов к курсу, аудитории материалов, публичные
ALL-маршруты и активы, миграция данных. - Запись/снятие групп на курс.
- Повышение курса: dry-run, журнал, идемпотентность, откат, выпуск.
- Академический отпуск/возврат, отчисление, выпуск, архив; усиленное удаление.
- (Позже) сервис-адаптер «Контингент»/
next.uniyar.ac.ru.
Открытых вопросов нет: все решения зафиксированы в §1.