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