"""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')