feat(db): миграции групп, курсов, статусов и профиля
This commit is contained in:
52
deploy/preflight/README.md
Normal file
52
deploy/preflight/README.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Preflight перед миграциями групп и курсов
|
||||||
|
|
||||||
|
**Перед любыми миграциями обязателен backup:** `./deploy/backup.sh backup`
|
||||||
|
(см. [`../BACKUP.md`](../BACKUP.md)). Preflight не заменяет backup.
|
||||||
|
|
||||||
|
Два скрипта только читают данные:
|
||||||
|
|
||||||
|
- `groups-courses.sql` — до миграций: проблемы, которые могут помешать;
|
||||||
|
- `groups-courses-post.sql` — после миграций и bootstrap курса C: проверка результата.
|
||||||
|
|
||||||
|
## До миграций
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -h localhost -U lms -d lms -f deploy/preflight/groups-courses.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверяет:
|
||||||
|
|
||||||
|
- группы, имя которых не разбирается шаблоном;
|
||||||
|
- одинаковые кортежи `(stream, degree, number, course, enrollment_year)`;
|
||||||
|
- группы без полного набора полей;
|
||||||
|
- одинаковые имена групп;
|
||||||
|
- студентов без текущей группы;
|
||||||
|
- дубли номеров лабораторных при переносе в один курс;
|
||||||
|
- дубли slug глав;
|
||||||
|
- объём материалов и активов.
|
||||||
|
|
||||||
|
## После миграций и bootstrap
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -h localhost -U lms -d lms -f deploy/preflight/groups-courses-post.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверяет:
|
||||||
|
|
||||||
|
- лабы без курса (должно быть `0` перед `SET NOT NULL`);
|
||||||
|
- главы без курса (должно быть `0`, если все текущие материалы курсовые);
|
||||||
|
- наличие ровно одного курса C;
|
||||||
|
- комментарии без автора;
|
||||||
|
- статусы аккаунтов;
|
||||||
|
- группы без полного разбора.
|
||||||
|
|
||||||
|
## Что делать с результатами
|
||||||
|
|
||||||
|
1. Группы с нестандартными именами — разобрать вручную и заполнить поля до
|
||||||
|
перехода на `NOT NULL`.
|
||||||
|
2. Одинаковые кортежи — исправить номер или год набора.
|
||||||
|
3. Дубли номеров лаб — перенумеровать или разнести по разным курсам до
|
||||||
|
включения `UNIQUE (number, course_id)` / `NOT NULL`.
|
||||||
|
4. Дубли slug — переименовать главы до частичных индексов.
|
||||||
|
5. После bootstrap курса C проверить, что `labs_without_course = 0` и
|
||||||
|
`chapters_without_course = 0`.
|
||||||
38
deploy/preflight/groups-courses-post.sql
Normal file
38
deploy/preflight/groups-courses-post.sql
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
-- Пост-миграционные проверки. Запускать после миграций и bootstrap курса C.
|
||||||
|
\pset pager off
|
||||||
|
|
||||||
|
\echo '=== 1. Лабы без курса (должно быть 0 перед SET NOT NULL) ==='
|
||||||
|
SELECT count(*) AS labs_without_course
|
||||||
|
FROM academic.labs
|
||||||
|
WHERE course_id IS NULL;
|
||||||
|
|
||||||
|
\echo '=== 2. Главы без курса (должно быть 0, если все текущие материалы курсовые) ==='
|
||||||
|
SELECT count(*) AS chapters_without_course
|
||||||
|
FROM academic.chapters
|
||||||
|
WHERE course_id IS NULL;
|
||||||
|
|
||||||
|
\echo '=== 3. Курсы (должен быть ровно один курс C) ==='
|
||||||
|
SELECT id, course_no, title, owner_account_id, status
|
||||||
|
FROM academic.courses
|
||||||
|
ORDER BY course_no;
|
||||||
|
|
||||||
|
\echo '=== 4. Комментарии без автора (старые записи) ==='
|
||||||
|
SELECT count(*) AS comments_without_author
|
||||||
|
FROM progress.grade_comments
|
||||||
|
WHERE author_account_id IS NULL;
|
||||||
|
|
||||||
|
\echo '=== 5. Аккаунты по ролям и статусам ==='
|
||||||
|
SELECT role, status, count(*) AS accounts
|
||||||
|
FROM identity.accounts
|
||||||
|
GROUP BY role, status
|
||||||
|
ORDER BY role, status;
|
||||||
|
|
||||||
|
\echo '=== 6. Группы без полного разбора ==='
|
||||||
|
SELECT id, name, stream, course, number, degree, enrollment_year
|
||||||
|
FROM identity.groups
|
||||||
|
WHERE stream IS NULL
|
||||||
|
OR course IS NULL
|
||||||
|
OR number IS NULL
|
||||||
|
OR degree IS NULL
|
||||||
|
OR enrollment_year IS NULL
|
||||||
|
ORDER BY name;
|
||||||
124
deploy/preflight/groups-courses.sql
Normal file
124
deploy/preflight/groups-courses.sql
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
-- Preflight (до миграций) для перехода на группы-когорты, курсы и перенос лаб/материалов.
|
||||||
|
-- Только чтение. Запускать на копии рабочей БД до применения миграций.
|
||||||
|
-- Пример:
|
||||||
|
-- psql -h localhost -U lms -d lms -f deploy/preflight/groups-courses.sql
|
||||||
|
|
||||||
|
\pset pager off
|
||||||
|
\echo '=== 1. Группы, не подходящие под шаблон ^(.+)-([0-9])([0-9]+)(БО|МО)$ ==='
|
||||||
|
SELECT id, name
|
||||||
|
FROM identity.groups
|
||||||
|
WHERE name !~ '^(.+)-([0-9])([0-9]+)(БО|МО)$'
|
||||||
|
ORDER BY name;
|
||||||
|
|
||||||
|
\echo '=== 2. Разобранные группы с одинаковым кортежем (stream, degree, number, course, enrollment_year) ==='
|
||||||
|
WITH params AS (
|
||||||
|
SELECT CASE
|
||||||
|
WHEN EXTRACT(MONTH FROM now()) >= 9 THEN EXTRACT(YEAR FROM now())
|
||||||
|
ELSE EXTRACT(YEAR FROM now()) - 1
|
||||||
|
END::int AS academic_year_start
|
||||||
|
),
|
||||||
|
parsed AS (
|
||||||
|
SELECT
|
||||||
|
g.name,
|
||||||
|
m[1] AS stream,
|
||||||
|
m[2]::int AS course,
|
||||||
|
m[3]::int AS number,
|
||||||
|
m[4] AS degree,
|
||||||
|
(SELECT academic_year_start FROM params) - (m[2]::int - 1) AS enrollment_year
|
||||||
|
FROM identity.groups g
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT regexp_match(g.name, '^(.+)-([0-9])([0-9]+)(БО|МО)$') AS m
|
||||||
|
) parsed_name
|
||||||
|
WHERE m IS NOT NULL
|
||||||
|
)
|
||||||
|
SELECT stream, degree, number, course, enrollment_year,
|
||||||
|
count(*) AS groups,
|
||||||
|
array_agg(name ORDER BY name) AS names
|
||||||
|
FROM parsed
|
||||||
|
GROUP BY stream, degree, number, course, enrollment_year
|
||||||
|
HAVING count(*) > 1
|
||||||
|
ORDER BY stream, degree, course, number;
|
||||||
|
|
||||||
|
\echo '=== 3. Группы, которые не разберутся шаблоном (потребуют ручного разбора) ==='
|
||||||
|
WITH parsed AS (
|
||||||
|
SELECT
|
||||||
|
g.id,
|
||||||
|
g.name,
|
||||||
|
m[1] AS stream,
|
||||||
|
m[2]::int AS course,
|
||||||
|
m[3]::int AS number,
|
||||||
|
m[4] AS degree
|
||||||
|
FROM identity.groups g
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT regexp_match(g.name, '^(.+)-([0-9])([0-9]+)(БО|МО)$') AS m
|
||||||
|
) parsed_name
|
||||||
|
)
|
||||||
|
SELECT id, name, stream, course, number, degree
|
||||||
|
FROM parsed
|
||||||
|
WHERE stream IS NULL
|
||||||
|
OR course IS NULL
|
||||||
|
OR number IS NULL
|
||||||
|
OR degree IS NULL
|
||||||
|
ORDER BY name;
|
||||||
|
|
||||||
|
\echo '=== 4. Одинаковые имена групп (после снятия UNIQUE станут допустимы) ==='
|
||||||
|
SELECT name, count(*) AS groups
|
||||||
|
FROM identity.groups
|
||||||
|
GROUP BY name
|
||||||
|
HAVING count(*) > 1
|
||||||
|
ORDER BY name;
|
||||||
|
|
||||||
|
\echo '=== 5. Студенты без текущей группы ==='
|
||||||
|
SELECT s.student_no, a.login, s.last_name, s.first_name
|
||||||
|
FROM identity.students s
|
||||||
|
JOIN identity.accounts a ON a.id = s.account_id
|
||||||
|
LEFT JOIN identity.enrollments e ON e.student_id = s.account_id
|
||||||
|
WHERE e.id IS NULL
|
||||||
|
ORDER BY s.student_no;
|
||||||
|
|
||||||
|
\echo '=== 6. Аккаунты по ролям (до появления статусов) ==='
|
||||||
|
SELECT role, count(*) AS accounts
|
||||||
|
FROM identity.accounts
|
||||||
|
GROUP BY role
|
||||||
|
ORDER BY role;
|
||||||
|
|
||||||
|
\echo '=== 7. Дубли номеров лабораторных (все лабы переезжают в один курс C) ==='
|
||||||
|
SELECT number,
|
||||||
|
count(*) AS labs,
|
||||||
|
array_agg(id ORDER BY created_at) AS ids,
|
||||||
|
array_agg(title ORDER BY created_at) AS titles
|
||||||
|
FROM academic.labs
|
||||||
|
GROUP BY number
|
||||||
|
HAVING count(*) > 1
|
||||||
|
ORDER BY number;
|
||||||
|
|
||||||
|
\echo '=== 8. Лабораторные по семестрам ==='
|
||||||
|
SELECT semester_id, number, count(*) AS labs
|
||||||
|
FROM academic.labs
|
||||||
|
GROUP BY semester_id, number
|
||||||
|
HAVING count(*) > 1
|
||||||
|
ORDER BY semester_id, number;
|
||||||
|
|
||||||
|
\echo '=== 9. Дубли slug глав (до перехода на частичные индексы) ==='
|
||||||
|
SELECT slug, count(*) AS chapters
|
||||||
|
FROM academic.chapters
|
||||||
|
GROUP BY slug
|
||||||
|
HAVING count(*) > 1
|
||||||
|
ORDER BY slug;
|
||||||
|
|
||||||
|
\echo '=== 10. Объём материалов ==='
|
||||||
|
SELECT
|
||||||
|
(SELECT count(*) FROM academic.chapters) AS chapters,
|
||||||
|
(SELECT count(*) FROM academic.sections) AS sections,
|
||||||
|
(SELECT count(*) FROM academic.articles) AS articles,
|
||||||
|
(SELECT count(*) FROM academic.article_assets) AS article_assets;
|
||||||
|
|
||||||
|
\echo '=== 11. Активы без статьи ==='
|
||||||
|
SELECT count(*) AS assets_without_article
|
||||||
|
FROM academic.article_assets
|
||||||
|
WHERE article_id IS NULL;
|
||||||
|
|
||||||
|
\echo '=== 12. Семестры ==='
|
||||||
|
SELECT id, title, year, active
|
||||||
|
FROM academic.semesters
|
||||||
|
ORDER BY year, title;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- Курс-дисциплина, назначения преподавателей и привязка групп.
|
||||||
|
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(),
|
||||||
|
CONSTRAINT courses_status_check CHECK (status IN ('ACTIVE', 'ARCHIVED'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX courses_status_idx ON courses (status);
|
||||||
|
|
||||||
|
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 INDEX course_teachers_account_idx ON course_teachers (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;
|
||||||
|
CREATE INDEX course_groups_group_idx ON course_groups (group_id)
|
||||||
|
WHERE removed_at IS NULL;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Лабораторные привязываются к курсу. Nullable-фаза: курс C создаёт
|
||||||
|
-- bootstrap после первого входа админа, затем выполняется post-bootstrap
|
||||||
|
-- миграция с SET NOT NULL.
|
||||||
|
ALTER TABLE labs ADD COLUMN course_id uuid REFERENCES courses (id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
ALTER TABLE labs DROP CONSTRAINT IF EXISTS labs_number_semester_unique;
|
||||||
|
|
||||||
|
-- Для лаб курса номер уникален в рамках курса. NULL-курс (до bootstrap)
|
||||||
|
-- не участвует в уникальности, поэтому старые дубли по семестрам не мешают
|
||||||
|
-- миграции; preflight показывает их до привязки.
|
||||||
|
CREATE UNIQUE INDEX labs_number_course_uidx ON labs (number, course_id);
|
||||||
|
CREATE INDEX labs_course_idx ON labs (course_id);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Материалы: глава может принадлежать курсу или быть глобальной с аудиторией.
|
||||||
|
ALTER TABLE chapters ADD COLUMN course_id uuid REFERENCES courses (id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE chapters ADD COLUMN audience varchar(16);
|
||||||
|
|
||||||
|
ALTER TABLE chapters
|
||||||
|
ADD CONSTRAINT chapters_audience_check CHECK (
|
||||||
|
audience IS NULL OR audience IN ('ALL', 'STUDENTS', 'TEACHERS')
|
||||||
|
),
|
||||||
|
ADD CONSTRAINT chapters_course_audience_check CHECK (
|
||||||
|
course_id IS NULL OR audience IS NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- slug уникален глобально только среди глобальных глав и внутри курса —
|
||||||
|
-- среди курсовых.
|
||||||
|
ALTER TABLE chapters DROP CONSTRAINT IF EXISTS 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;
|
||||||
|
CREATE INDEX chapters_course_idx ON chapters (course_id);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Активы уже связаны со статьёй, но выборка по статье не индексирована.
|
||||||
|
CREATE INDEX article_assets_article_idx ON article_assets (article_id);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Добавляем роль преподавателя. Старые ADMIN/STUDENT сохраняются.
|
||||||
|
ALTER TABLE accounts DROP CONSTRAINT IF EXISTS accounts_role_check;
|
||||||
|
ALTER TABLE accounts
|
||||||
|
ADD CONSTRAINT accounts_role_check CHECK (role IN ('ADMIN', 'TEACHER', 'STUDENT'));
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
-- Поля группы-когорты. На этой фазе nullable: существующие группы могут не
|
||||||
|
-- соответствовать шаблону имени и требуют ручного разбора.
|
||||||
|
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';
|
||||||
|
|
||||||
|
-- Имя больше не глобально уникально: одноимённые группы разных наборов легальны.
|
||||||
|
ALTER TABLE groups DROP CONSTRAINT IF EXISTS groups_name_key;
|
||||||
|
|
||||||
|
-- Best-effort бэкфилл из имени. Учебный год начинается в сентябре.
|
||||||
|
WITH params AS (
|
||||||
|
SELECT CASE
|
||||||
|
WHEN EXTRACT(MONTH FROM now()) >= 9 THEN EXTRACT(YEAR FROM now())
|
||||||
|
ELSE EXTRACT(YEAR FROM now()) - 1
|
||||||
|
END::smallint AS academic_year_start
|
||||||
|
),
|
||||||
|
parsed AS (
|
||||||
|
SELECT
|
||||||
|
g.id,
|
||||||
|
m[1] AS stream,
|
||||||
|
m[2]::smallint AS course,
|
||||||
|
m[3]::smallint AS number,
|
||||||
|
m[4] AS degree
|
||||||
|
FROM groups g
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT regexp_match(g.name, '^(.+)-([0-9])([0-9]+)(БО|МО)$') AS m
|
||||||
|
) parsed_name
|
||||||
|
)
|
||||||
|
UPDATE groups g
|
||||||
|
SET stream = p.stream,
|
||||||
|
course = p.course,
|
||||||
|
number = p.number,
|
||||||
|
degree = p.degree,
|
||||||
|
enrollment_year = ((SELECT academic_year_start FROM params) - (p.course - 1))::smallint
|
||||||
|
FROM parsed p
|
||||||
|
WHERE g.id = p.id
|
||||||
|
AND p.stream IS NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE groups
|
||||||
|
ADD CONSTRAINT groups_status_check CHECK (status IN ('ACTIVE', 'GRADUATED', 'ARCHIVED')),
|
||||||
|
ADD CONSTRAINT groups_degree_check CHECK (degree IS NULL OR degree IN ('БО', 'МО')),
|
||||||
|
ADD CONSTRAINT groups_course_range CHECK (
|
||||||
|
(degree IS NULL OR course IS NULL)
|
||||||
|
OR (degree = 'БО' AND course BETWEEN 1 AND 4)
|
||||||
|
OR (degree = 'МО' AND course BETWEEN 1 AND 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Уникальность когорты для уже разобранных групп. После ручного разбора
|
||||||
|
-- и подтверждения года набора ограничение станет полным (без WHERE).
|
||||||
|
CREATE UNIQUE INDEX groups_cohort_unique
|
||||||
|
ON groups (stream, degree, number, course, enrollment_year)
|
||||||
|
WHERE stream IS NOT NULL
|
||||||
|
AND degree IS NOT NULL
|
||||||
|
AND number IS NOT NULL
|
||||||
|
AND course IS NOT NULL
|
||||||
|
AND enrollment_year IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX groups_status_idx ON groups (status);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Академический статус студента и год набора.
|
||||||
|
ALTER TABLE students
|
||||||
|
ADD COLUMN status varchar(16) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
ADD COLUMN enrollment_year smallint;
|
||||||
|
|
||||||
|
ALTER TABLE students
|
||||||
|
ADD CONSTRAINT students_status_check CHECK (
|
||||||
|
status IN ('ACTIVE', 'ACADEMIC_LEAVE', 'EXPELLED', 'GRADUATED', 'ARCHIVED')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Год набора наследуется от текущей группы; для групп без разобранного года
|
||||||
|
-- остаётся NULL и требует подтверждения админом.
|
||||||
|
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
|
||||||
|
AND s.enrollment_year IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX students_status_idx ON students (status);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- Журнал повышения курса: шапка запуска и по-элементный план/результат.
|
||||||
|
CREATE TABLE promotion_runs (
|
||||||
|
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||||
|
year integer NOT NULL,
|
||||||
|
mode varchar(16) NOT NULL,
|
||||||
|
actor_account_id uuid REFERENCES accounts (id) ON DELETE SET NULL,
|
||||||
|
status varchar(16) NOT NULL DEFAULT 'PLANNED',
|
||||||
|
started_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
finished_at timestamptz,
|
||||||
|
result jsonb,
|
||||||
|
CONSTRAINT promotion_runs_mode_check CHECK (mode IN ('DRY_RUN', 'APPLY')),
|
||||||
|
CONSTRAINT promotion_runs_status_check CHECK (
|
||||||
|
status IN ('PLANNED', 'RUNNING', 'COMPLETED', 'FAILED', 'ROLLED_BACK')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Один успешный (или выполняющийся) APPLY на год. Неудачный запуск можно
|
||||||
|
-- повторить; после отката статус ROLLED_BACK также не блокирует новый запуск.
|
||||||
|
CREATE UNIQUE INDEX promotion_runs_apply_year_uidx
|
||||||
|
ON promotion_runs (year)
|
||||||
|
WHERE mode = 'APPLY' AND status IN ('RUNNING', 'COMPLETED');
|
||||||
|
|
||||||
|
CREATE INDEX promotion_runs_year_idx ON promotion_runs (year, mode);
|
||||||
|
|
||||||
|
CREATE TABLE promotion_run_items (
|
||||||
|
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||||
|
run_id uuid NOT NULL REFERENCES promotion_runs (id) ON DELETE CASCADE,
|
||||||
|
entity_type varchar(16) NOT NULL,
|
||||||
|
entity_id uuid NOT NULL,
|
||||||
|
action varchar(32) NOT NULL,
|
||||||
|
before_state jsonb,
|
||||||
|
after_state jsonb,
|
||||||
|
status varchar(16) NOT NULL DEFAULT 'PLANNED',
|
||||||
|
error text NOT NULL DEFAULT '',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT promotion_run_items_entity_check CHECK (entity_type IN ('GROUP', 'STUDENT')),
|
||||||
|
CONSTRAINT promotion_run_items_status_check CHECK (
|
||||||
|
status IN ('PLANNED', 'APPLIED', 'SKIPPED', 'ERROR', 'ROLLED_BACK')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX promotion_run_items_run_idx ON promotion_run_items (run_id);
|
||||||
|
CREATE INDEX promotion_run_items_entity_idx ON promotion_run_items (entity_type, entity_id);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- История членства: переводы, академ, возврат, выпуск, отчисление, повышение.
|
||||||
|
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,
|
||||||
|
reason text NOT NULL DEFAULT '',
|
||||||
|
actor_account_id uuid REFERENCES accounts (id) ON DELETE SET NULL,
|
||||||
|
promotion_run_id uuid REFERENCES promotion_runs (id) ON DELETE SET NULL,
|
||||||
|
at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT membership_history_action_check CHECK (
|
||||||
|
action IN (
|
||||||
|
'ENROLL', 'TRANSFER', 'ACADEMIC', 'RETURN', 'GRADUATE',
|
||||||
|
'EXPELL', 'PROMOTE', 'IMPORT', 'ARCHIVE', 'RESTORE'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX membership_history_student_idx ON membership_history (student_id, at DESC);
|
||||||
|
CREATE INDEX membership_history_from_group_idx ON membership_history (from_group_id);
|
||||||
|
CREATE INDEX membership_history_to_group_idx ON membership_history (to_group_id);
|
||||||
|
CREATE INDEX membership_history_promotion_run_idx ON membership_history (promotion_run_id);
|
||||||
|
|
||||||
|
-- Текущие зачисления становятся первой записью истории.
|
||||||
|
INSERT INTO membership_history (student_id, to_group_id, action, at)
|
||||||
|
SELECT student_id, group_id, 'ENROLL', enrolled_at
|
||||||
|
FROM enrollments;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Статус аккаунта для всех пользователей: архив закрывает вход.
|
||||||
|
ALTER TABLE accounts
|
||||||
|
ADD COLUMN status varchar(16) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
ADD COLUMN archived_at timestamptz;
|
||||||
|
|
||||||
|
ALTER TABLE accounts
|
||||||
|
ADD CONSTRAINT accounts_status_check CHECK (status IN ('ACTIVE', 'ARCHIVED'));
|
||||||
|
|
||||||
|
CREATE INDEX accounts_status_idx ON accounts (status);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Автор комментария и последнего изменения оценки — для профиля студента курса.
|
||||||
|
-- Старые записи остаются без автора и отображаются как «автор не указан».
|
||||||
|
ALTER TABLE grade_comments ADD COLUMN author_account_id uuid;
|
||||||
|
ALTER TABLE lab_grades ADD COLUMN updated_by_account_id uuid;
|
||||||
|
|
||||||
|
-- Комментарии уже индексированы grade_comments_grade_idx (student_id, lab_id).
|
||||||
|
CREATE INDEX lab_grades_lab_idx ON lab_grades (lab_id);
|
||||||
Reference in New Issue
Block a user