367 lines
18 KiB
Python
367 lines
18 KiB
Python
"""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')
|