Files
vidconf/backend/alembic/versions/f418dd65e7b1_dynamic_conferences.py

367 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""dynamic conferences (ADR-001)
Реализует раздел «Модель данных» ADR-001 (`docs/architecture/adr/001-dynamic-conferences-pivot.md`):
- переименование `conferences` (сеанс) в `conference_sessions`;
- новая сущность `conferences` (конференция: номер, ссылка, владелец, статус,
закрепление/закрытость, расписание, recurrence);
- backfill новых `conferences` из существующих `rooms` (по одной на комнату,
на которую ссылается хотя бы один сеанс) и перевязка `conference_sessions`;
- переименование `conference_id` -> `session_id` в `phrases`/`chat_messages`/
`conference_participants`;
- `conference_participants`: `user_id` NULLABLE + `guest_id` + CHECK «ровно
одно из двух заполнено»;
- новая таблица `guest_access`;
- удаление `booking_participants`, `room_bookings` (вместе с ней уходит
EXCLUDE-constraint — см. ADR-001, п.5) и `rooms`.
Продакшен-данных нет (см. контекст ADR-001) — backfill рассчитан на
непустую тестовую/дев БД, но не падает и на пустой (см. `_backfill_conferences_from_rooms`).
Revision ID: f418dd65e7b1
Revises: 299053c6f7b8
Create Date: 2026-07-16 12:00:00.000000
"""
import secrets
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from sqlalchemy.engine import Connection
# revision identifiers, used by Alembic.
revision: str = 'f418dd65e7b1'
down_revision: Union[str, Sequence[str], None] = '299053c6f7b8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _generate_conference_number(used: set[str]) -> str:
"""Сгенерировать 9-значный номер конференции, уникальный в рамках backfill.
Логика идентична `services/conference_ids.py::generate_number` — миграции
не импортируют прикладной код (он может измениться со временем и
сломать применение старых ревизий), поэтому продублирована здесь.
"""
while True:
first_digit = str(secrets.randbelow(9) + 1)
rest_digits = "".join(str(secrets.randbelow(10)) for _ in range(8))
number = first_digit + rest_digits
if number not in used:
used.add(number)
return number
def _backfill_conferences_from_rooms(connection: Connection) -> None:
"""Создать по одной `conferences`-записи на каждую `room`, встречающуюся в сеансах.
`slug` = `permanent_link` комнаты (сохраняет действующие постоянные
ссылки), `status='ended'` (это уже прожитая история, а не активная
конференция), `owner_id` не определён (комнаты были общими). На пустой
БД (нет строк `rooms`) цикл просто не выполняется.
"""
rooms = connection.execute(
sa.text(
"""
SELECT DISTINCT r.id, r.name, r.permanent_link, r.is_pinned, r.created_at
FROM rooms r
WHERE EXISTS (SELECT 1 FROM conference_sessions cs WHERE cs.room_id = r.id)
"""
)
).mappings().all()
used_numbers: set[str] = set()
for room in rooms:
connection.execute(
sa.text(
"""
INSERT INTO conferences
(id, number, slug, title, status, is_pinned, created_at)
VALUES
(gen_random_uuid(), :number, :slug, :title, 'ended', :is_pinned, :created_at)
"""
),
{
"number": _generate_conference_number(used_numbers),
"slug": room["permanent_link"],
"title": room["name"],
"is_pinned": room["is_pinned"],
"created_at": room["created_at"],
},
)
def upgrade() -> None:
"""Upgrade schema."""
bind = op.get_bind()
# 1. conferences (сеанс) -> conference_sessions. FK-constraints и данные
# сохраняются автоматически (Postgres переносит их вместе с таблицей,
# constraint-имена, унаследованные от старого имени таблицы, не переименовываются —
# это косметика, на работу не влияет). Индексы на удаляемой ниже колонке
# `room_id` пересоздаём под новую модель.
op.rename_table('conferences', 'conference_sessions')
op.drop_index('ix_conferences_room_id_t_start', table_name='conference_sessions')
op.drop_index('ix_conferences_pipeline_status', table_name='conference_sessions')
op.create_index(
'ix_conference_sessions_pipeline_status', 'conference_sessions', ['pipeline_status']
)
# 2. Новая сущность Conference + её ENUM жизненного цикла (ADR-001, п.2).
# Тип создаётся автоматически вместе с таблицей (create_type=True по
# умолчанию) — отдельный `.create()` здесь не нужен и приводит к
# DuplicateObjectError при повторном создании тем же вызовом create_table.
conference_status = postgresql.ENUM('scheduled', 'active', 'ended', name='conference_status')
op.create_table(
'conferences',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
sa.Column('number', sa.String(length=9), nullable=False),
sa.Column('slug', sa.String(length=22), nullable=False),
sa.Column('title', sa.String(length=255), nullable=True),
sa.Column('owner_id', sa.UUID(), nullable=True),
sa.Column('status', conference_status, server_default='scheduled', nullable=False),
sa.Column('is_pinned', sa.Boolean(), server_default='false', nullable=False),
sa.Column('is_closed', sa.Boolean(), server_default='false', nullable=False),
sa.Column('password_hash', sa.Text(), nullable=True),
sa.Column('scheduled_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('duration_minutes', sa.Integer(), nullable=True),
sa.Column('recurrence', postgresql.JSONB(), nullable=True),
sa.Column('ended_at', sa.DateTime(timezone=True), nullable=True),
sa.Column(
'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'),
nullable=False,
),
sa.CheckConstraint(
'is_closed = false OR password_hash IS NOT NULL',
name='ck_conferences_closed_requires_password',
),
sa.CheckConstraint(
'recurrence IS NULL OR is_pinned = true',
name='ck_conferences_recurrence_requires_pinned',
),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('number'),
sa.UniqueConstraint('slug'),
)
# 3. Backfill: одна конференция на каждую room, на которую ссылались сеансы.
_backfill_conferences_from_rooms(bind)
# 4. Перевязка: conference_sessions получает conference_id, найденный через
# исходный room_id (join по slug == permanent_link, который backfill
# сохранил равным исходной ссылке комнаты), после чего room_id/booking_id уходят.
op.add_column('conference_sessions', sa.Column('conference_id', sa.UUID(), nullable=True))
op.execute(
"""
UPDATE conference_sessions cs
SET conference_id = c.id
FROM rooms r
JOIN conferences c ON c.slug = r.permanent_link
WHERE cs.room_id = r.id
"""
)
op.alter_column('conference_sessions', 'conference_id', nullable=False)
op.create_foreign_key(
'conference_sessions_conference_id_fkey',
'conference_sessions', 'conferences',
['conference_id'], ['id'], ondelete='CASCADE',
)
op.create_index(
'ix_conference_sessions_conference_id_t_start',
'conference_sessions', ['conference_id', 't_start'],
)
op.drop_constraint('conferences_room_id_fkey', 'conference_sessions', type_='foreignkey')
op.drop_constraint('conferences_booking_id_fkey', 'conference_sessions', type_='foreignkey')
op.drop_column('conference_sessions', 'room_id')
op.drop_column('conference_sessions', 'booking_id')
# 5. conference_id -> session_id в phrases/chat_messages/conference_participants.
# FK на conference_sessions(id) сохраняется автоматически (см. п.1); индексы
# переименовываются вслед за колонкой.
op.alter_column('phrases', 'conference_id', new_column_name='session_id')
op.execute('ALTER INDEX ix_phrases_conference_id_t_start RENAME TO ix_phrases_session_id_t_start')
op.alter_column('chat_messages', 'conference_id', new_column_name='session_id')
op.execute(
'ALTER INDEX ix_chat_messages_conference_id_created_at '
'RENAME TO ix_chat_messages_session_id_created_at'
)
op.alter_column('conference_participants', 'conference_id', new_column_name='session_id')
op.execute(
'ALTER INDEX ix_conference_participants_conference_id '
'RENAME TO ix_conference_participants_session_id'
)
# 6. guest_access — создаётся до правки conference_participants, т.к. её
# новый guest_id ссылается на эту таблицу (порядок из-за FK-зависимости
# отличается от порядка перечисления в ADR-001, итоговая схема та же).
op.create_table(
'guest_access',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
sa.Column('conference_id', sa.UUID(), nullable=False),
sa.Column('display_name', sa.String(length=255), nullable=False),
sa.Column('email', sa.String(length=320), nullable=True),
sa.Column(
'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'),
nullable=False,
),
sa.ForeignKeyConstraint(['conference_id'], ['conferences.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
# 7. conference_participants: user_id nullable, guest_id, CHECK «ровно одно из двух».
op.alter_column('conference_participants', 'user_id', nullable=True)
op.add_column('conference_participants', sa.Column('guest_id', sa.UUID(), nullable=True))
op.create_foreign_key(
'conference_participants_guest_id_fkey',
'conference_participants', 'guest_access',
['guest_id'], ['id'],
)
op.create_check_constraint(
'ck_conference_participants_exactly_one_identity',
'conference_participants',
'(user_id IS NOT NULL)::int + (guest_id IS NOT NULL)::int = 1',
)
# 8. Комнаты и бронирование уходят вместе с EXCLUDE-constraint'ом
# (ADR-001, п.5); список допущенных участников брони не переносится —
# закрытая конференция теперь защищена только паролем.
op.drop_table('booking_participants')
op.drop_table('room_bookings')
op.drop_table('rooms')
def downgrade() -> None:
"""Downgrade schema.
Разрушительная миграция (ADR-001: «продакшен-данных нет — допустима
структурная миграция»): downgrade восстанавливает схему, но не исходные
данные комнат/броней/номеров — они не хранились обратимо после backfill.
"""
op.create_table(
'rooms',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('is_pinned', sa.Boolean(), server_default='false', nullable=False),
sa.Column('permanent_link', sa.String(length=64), nullable=False),
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
sa.Column(
'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'),
nullable=False,
),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('permanent_link'),
)
op.create_table(
'room_bookings',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
sa.Column('room_id', sa.UUID(), nullable=False),
sa.Column('organizer_id', sa.UUID(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=True),
sa.Column('period', postgresql.TSTZRANGE(), nullable=False),
sa.Column('is_closed', sa.Boolean(), server_default='false', nullable=False),
sa.Column('password_hash', sa.Text(), nullable=True),
sa.Column('access_link', sa.String(length=43), nullable=False),
sa.Column(
'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'),
nullable=False,
),
postgresql.ExcludeConstraint(
(sa.column('room_id'), '='), (sa.column('period'), '&&'),
using='gist', name='excl_room_bookings_overlap',
),
sa.CheckConstraint('NOT isempty(period)', name='ck_room_bookings_period_not_empty'),
sa.ForeignKeyConstraint(['organizer_id'], ['users.id'], ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('access_link'),
)
op.create_table(
'booking_participants',
sa.Column('booking_id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['booking_id'], ['room_bookings.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('booking_id', 'user_id'),
)
op.drop_constraint(
'ck_conference_participants_exactly_one_identity', 'conference_participants',
type_='check',
)
op.drop_constraint(
'conference_participants_guest_id_fkey', 'conference_participants', type_='foreignkey'
)
op.drop_column('conference_participants', 'guest_id')
op.alter_column('conference_participants', 'user_id', nullable=False)
op.drop_table('guest_access')
op.execute(
'ALTER INDEX ix_conference_participants_session_id '
'RENAME TO ix_conference_participants_conference_id'
)
op.alter_column('conference_participants', 'session_id', new_column_name='conference_id')
op.execute(
'ALTER INDEX ix_chat_messages_session_id_created_at '
'RENAME TO ix_chat_messages_conference_id_created_at'
)
op.alter_column('chat_messages', 'session_id', new_column_name='conference_id')
op.execute('ALTER INDEX ix_phrases_session_id_t_start RENAME TO ix_phrases_conference_id_t_start')
op.alter_column('phrases', 'session_id', new_column_name='conference_id')
op.add_column('conference_sessions', sa.Column('room_id', sa.UUID(), nullable=True))
op.add_column('conference_sessions', sa.Column('booking_id', sa.UUID(), nullable=True))
# Реконструируем rooms из conferences, на которые ссылаются сеансы —
# операция, обратная backfill'у в upgrade(). `is_active` не хранилось
# раздельно от новой модели — восстанавливаем как `true`.
op.execute(
"""
INSERT INTO rooms (id, name, permanent_link, is_pinned, is_active, created_at)
SELECT gen_random_uuid(), COALESCE(c.title, c.slug), c.slug, c.is_pinned, true, c.created_at
FROM conferences c
WHERE EXISTS (SELECT 1 FROM conference_sessions cs WHERE cs.conference_id = c.id)
"""
)
op.execute(
"""
UPDATE conference_sessions cs
SET room_id = r.id
FROM conferences c
JOIN rooms r ON r.permanent_link = c.slug
WHERE cs.conference_id = c.id
"""
)
op.create_foreign_key(
'conferences_room_id_fkey', 'conference_sessions', 'rooms', ['room_id'], ['id'],
ondelete='CASCADE',
)
op.create_foreign_key(
'conferences_booking_id_fkey', 'conference_sessions', 'room_bookings', ['booking_id'],
['id'], ondelete='SET NULL',
)
op.drop_index('ix_conference_sessions_conference_id_t_start', table_name='conference_sessions')
op.drop_constraint(
'conference_sessions_conference_id_fkey', 'conference_sessions', type_='foreignkey'
)
op.drop_column('conference_sessions', 'conference_id')
op.alter_column('conference_sessions', 'room_id', nullable=False)
op.drop_table('conferences')
postgresql.ENUM(name='conference_status').drop(op.get_bind(), checkfirst=True)
op.drop_index('ix_conference_sessions_pipeline_status', table_name='conference_sessions')
op.create_index(
'ix_conferences_room_id_t_start', 'conference_sessions', ['room_id', 't_start']
)
op.create_index(
'ix_conferences_pipeline_status', 'conference_sessions', ['pipeline_status']
)
op.rename_table('conference_sessions', 'conferences')