feat(db): миграции групп, курсов, статусов и профиля

This commit is contained in:
2026-10-26 15:47:11 +03:00
parent cde04487e8
commit de9535d35c
14 changed files with 460 additions and 0 deletions

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -0,0 +1,2 @@
-- Активы уже связаны со статьёй, но выборка по статье не индексирована.
CREATE INDEX article_assets_article_idx ON article_assets (article_id);

View File

@@ -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'));

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);