Первоначальная версия VidConf
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
"""auth email verification and livekit webhook events
|
||||
|
||||
Revision ID: 149d70424ae0
|
||||
Revises: 1e2e34a0cb06
|
||||
Create Date: 2026-07-15 03:54:52.548246
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '149d70424ae0'
|
||||
down_revision: Union[str, Sequence[str], None] = '1e2e34a0cb06'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('livekit_webhook_events',
|
||||
sa.Column('event_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('event_type', sa.String(length=64), nullable=False),
|
||||
sa.Column('received_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('event_id')
|
||||
)
|
||||
op.create_table('email_verification_tokens',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('token_hash', sa.Text(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('token_hash')
|
||||
)
|
||||
op.create_index('ix_email_verification_tokens_user_id', 'email_verification_tokens', ['user_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index('ix_email_verification_tokens_user_id', table_name='email_verification_tokens')
|
||||
op.drop_table('email_verification_tokens')
|
||||
op.drop_table('livekit_webhook_events')
|
||||
# ### end Alembic commands ###
|
||||
136
backend/alembic/versions/1e2e34a0cb06_initial_schema.py
Normal file
136
backend/alembic/versions/1e2e34a0cb06_initial_schema.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 1e2e34a0cb06
|
||||
Revises:
|
||||
Create Date: 2026-07-15 01:48:41.232692
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '1e2e34a0cb06'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# Required for the `room_bookings` EXCLUDE USING gist constraint, which
|
||||
# mixes an equality operator (room_id) with a range overlap operator
|
||||
# (period) — btree_gist supplies the GiST operator class for `=` on
|
||||
# non-range types such as uuid.
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS btree_gist")
|
||||
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
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('users',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('name_user', sa.String(length=255), nullable=False),
|
||||
sa.Column('password_hash', sa.Text(), nullable=False),
|
||||
sa.Column('role', sa.String(length=16), server_default='user', nullable=False),
|
||||
sa.Column('email_verified', sa.Boolean(), server_default='false', nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.CheckConstraint("role IN ('admin', 'user')", name='ck_users_role'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('email')
|
||||
)
|
||||
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('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')
|
||||
)
|
||||
op.create_table('conferences',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('room_id', sa.UUID(), nullable=False),
|
||||
sa.Column('booking_id', sa.UUID(), nullable=True),
|
||||
sa.Column('title', sa.String(length=255), nullable=True),
|
||||
sa.Column('t_start', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('t_end', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('summary_data', sa.Text(), nullable=True),
|
||||
sa.Column('pipeline_status', sa.Enum('recording', 'transcribing', 'summarizing', 'notified', 'failed', name='pipeline_status'), server_default='recording', nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['booking_id'], ['room_bookings.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_conferences_pipeline_status', 'conferences', ['pipeline_status'], unique=False)
|
||||
op.create_index('ix_conferences_room_id_t_start', 'conferences', ['room_id', 't_start'], unique=False)
|
||||
op.create_table('chat_messages',
|
||||
sa.Column('id', sa.BigInteger(), sa.Identity(always=True), nullable=False),
|
||||
sa.Column('conference_id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('text', sa.Text(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['conference_id'], ['conferences.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_chat_messages_conference_id_created_at', 'chat_messages', ['conference_id', 'created_at'], unique=False)
|
||||
op.create_table('conference_participants',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('conference_id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('joined_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('left_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['conference_id'], ['conferences.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_conference_participants_conference_id', 'conference_participants', ['conference_id'], unique=False)
|
||||
op.create_table('phrases',
|
||||
sa.Column('id', sa.BigInteger(), sa.Identity(always=True), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('conference_id', sa.UUID(), nullable=False),
|
||||
sa.Column('data', sa.Text(), nullable=False),
|
||||
sa.Column('t_start', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('t_end', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['conference_id'], ['conferences.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_phrases_conference_id_t_start', 'phrases', ['conference_id', 't_start'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index('ix_phrases_conference_id_t_start', table_name='phrases')
|
||||
op.drop_table('phrases')
|
||||
op.drop_index('ix_conference_participants_conference_id', table_name='conference_participants')
|
||||
op.drop_table('conference_participants')
|
||||
op.drop_index('ix_chat_messages_conference_id_created_at', table_name='chat_messages')
|
||||
op.drop_table('chat_messages')
|
||||
op.drop_index('ix_conferences_room_id_t_start', table_name='conferences')
|
||||
op.drop_index('ix_conferences_pipeline_status', table_name='conferences')
|
||||
op.drop_table('conferences')
|
||||
op.drop_table('room_bookings')
|
||||
op.drop_table('users')
|
||||
op.drop_table('rooms')
|
||||
# ### end Alembic commands ###
|
||||
postgresql.ENUM(name="pipeline_status").drop(op.get_bind(), checkfirst=True)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""booking access link and participants
|
||||
|
||||
Revision ID: 299053c6f7b8
|
||||
Revises: 149d70424ae0
|
||||
Create Date: 2026-07-15 16:52:55.554576
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '299053c6f7b8'
|
||||
down_revision: Union[str, Sequence[str], None] = '149d70424ae0'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
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'),
|
||||
)
|
||||
|
||||
# Колонка добавляется nullable, чтобы не упасть на уже существующих
|
||||
# строках; backfill генерирует уникальный slug каждой существующей
|
||||
# брони, после чего ограничение NOT NULL накладывается отдельным шагом.
|
||||
op.add_column('room_bookings', sa.Column('access_link', sa.String(length=43), nullable=True))
|
||||
# gen_random_bytes живёт в pgcrypto (в отличие от gen_random_uuid,
|
||||
# встроенного в ядро с PG13) — расширение включается здесь же.
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto")
|
||||
op.execute("UPDATE room_bookings SET access_link = encode(gen_random_bytes(16), 'hex')")
|
||||
op.alter_column('room_bookings', 'access_link', nullable=False)
|
||||
op.create_unique_constraint(
|
||||
'uq_room_bookings_access_link', 'room_bookings', ['access_link']
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_constraint('uq_room_bookings_access_link', 'room_bookings', type_='unique')
|
||||
op.drop_column('room_bookings', 'access_link')
|
||||
op.drop_table('booking_participants')
|
||||
69
backend/alembic/versions/504791847d4f_chat_guest_authors.py
Normal file
69
backend/alembic/versions/504791847d4f_chat_guest_authors.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""chat guest authors
|
||||
|
||||
Разрешить сообщения чата (`chat_messages`) от гостей и хранить снапшот имени
|
||||
автора:
|
||||
- `user_id` становится nullable — автором может быть гость;
|
||||
- `guest_access_id` — необязательная ссылка на `guest_access`, `ON DELETE
|
||||
CASCADE` (удаление гостевой записи удаляет и его сообщения чата);
|
||||
- `author_name` — снапшот отображаемого имени из LiveKit-токена на момент
|
||||
отправки; добавляется nullable, backfill из `users.name_user` для уже
|
||||
существующих строк (все они с `user_id`, т.к. гостевого автора раньше не
|
||||
было), затем ужесточается до `NOT NULL`;
|
||||
- `ck_chat_messages_author` — ровно один из `user_id`/`guest_access_id`
|
||||
обязателен (как у `ConferenceParticipant`, ADR-001, п.6).
|
||||
|
||||
Revision ID: 504791847d4f
|
||||
Revises: 9d37822e4513
|
||||
Create Date: 2026-07-18 18:45:20.921653
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '504791847d4f'
|
||||
down_revision: Union[str, Sequence[str], None] = '9d37822e4513'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.add_column('chat_messages', sa.Column('guest_access_id', sa.UUID(), nullable=True))
|
||||
op.add_column('chat_messages', sa.Column('author_name', sa.String(length=255), nullable=True))
|
||||
op.alter_column('chat_messages', 'user_id', existing_type=sa.UUID(), nullable=True)
|
||||
|
||||
op.create_foreign_key(
|
||||
'fk_chat_messages_guest_access_id_guest_access',
|
||||
'chat_messages', 'guest_access', ['guest_access_id'], ['id'], ondelete='CASCADE',
|
||||
)
|
||||
|
||||
# Backfill: до этой миграции автор всегда был зарегистрированным
|
||||
# пользователем — берём снапшот его текущего имени.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE chat_messages cm
|
||||
SET author_name = u.name_user
|
||||
FROM users u
|
||||
WHERE cm.user_id = u.id
|
||||
"""
|
||||
)
|
||||
op.alter_column('chat_messages', 'author_name', existing_type=sa.String(length=255), nullable=False)
|
||||
|
||||
op.create_check_constraint(
|
||||
'ck_chat_messages_author',
|
||||
'chat_messages',
|
||||
'user_id IS NOT NULL OR guest_access_id IS NOT NULL',
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_constraint('ck_chat_messages_author', 'chat_messages', type_='check')
|
||||
op.drop_constraint('fk_chat_messages_guest_access_id_guest_access', 'chat_messages', type_='foreignkey')
|
||||
op.alter_column('chat_messages', 'user_id', existing_type=sa.UUID(), nullable=False)
|
||||
op.drop_column('chat_messages', 'author_name')
|
||||
op.drop_column('chat_messages', 'guest_access_id')
|
||||
108
backend/alembic/versions/5970bf64fc43_settings_notifications.py
Normal file
108
backend/alembic/versions/5970bf64fc43_settings_notifications.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""settings and notifications
|
||||
|
||||
Настройки инстанса и журнал почтовых рассылок:
|
||||
- `instance_settings` — key-value настройки инстанса (JSONB), бутстрап из
|
||||
`config/plugins.yaml` в lifespan backend (`services/instance_settings.py`);
|
||||
- `email_deliveries` — идемпотентность рассылки саммари (уникальный частичный
|
||||
индекс по `(session_id, recipient_email)` при `kind='summary'`) и журнал
|
||||
приглашений (`kind='invitation'`, без unique — переслать обновление
|
||||
расписания обязано дублировать письмо);
|
||||
- `conferences.summary_recipients` — переопределение рассылки для конкретной
|
||||
конференции (`NULL` = дефолт инстанса), `conferences.ics_sequence` —
|
||||
счётчик изменений расписания для VEVENT `SEQUENCE`;
|
||||
- `users.is_blocked` — блокировка администратором, проверяется немедленно в
|
||||
`api/deps.py::_user_from_token`.
|
||||
|
||||
Revision ID: 5970bf64fc43
|
||||
Revises: 88aa676ac140
|
||||
Create Date: 2026-07-17 23:24:46.699304
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '5970bf64fc43'
|
||||
down_revision: Union[str, Sequence[str], None] = '88aa676ac140'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table(
|
||||
'instance_settings',
|
||||
sa.Column('key', sa.String(), nullable=False),
|
||||
sa.Column('value', postgresql.JSONB(), nullable=False),
|
||||
sa.Column(
|
||||
'updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint('key'),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'email_deliveries',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('session_id', sa.UUID(), nullable=True),
|
||||
sa.Column('conference_id', sa.UUID(), nullable=True),
|
||||
sa.Column('recipient_email', sa.String(length=320), nullable=False),
|
||||
sa.Column('kind', sa.String(length=16), nullable=False),
|
||||
sa.Column(
|
||||
'sent_at', sa.DateTime(timezone=True), server_default=sa.text('now()'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint("kind IN ('summary', 'invitation')", name='ck_email_deliveries_kind'),
|
||||
sa.CheckConstraint(
|
||||
'(kind = \'summary\') = (session_id IS NOT NULL)',
|
||||
name='ck_email_deliveries_summary_has_session',
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
'(kind = \'invitation\') = (conference_id IS NOT NULL)',
|
||||
name='ck_email_deliveries_invitation_has_conference',
|
||||
),
|
||||
sa.ForeignKeyConstraint(['session_id'], ['conference_sessions.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['conference_id'], ['conferences.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index(
|
||||
'uq_email_deliveries_summary', 'email_deliveries', ['session_id', 'recipient_email'],
|
||||
unique=True, postgresql_where=sa.text("kind = 'summary'"),
|
||||
)
|
||||
op.create_index(
|
||||
'ix_email_deliveries_conference', 'email_deliveries', ['conference_id'],
|
||||
)
|
||||
|
||||
op.add_column('conferences', sa.Column('summary_recipients', sa.String(length=16), nullable=True))
|
||||
op.add_column(
|
||||
'conferences',
|
||||
sa.Column('ics_sequence', sa.Integer(), server_default='0', nullable=False),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
'ck_conferences_summary_recipients',
|
||||
'conferences',
|
||||
"summary_recipients IN ('all', 'owner')",
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
'users', sa.Column('is_blocked', sa.Boolean(), server_default='false', nullable=False)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_column('users', 'is_blocked')
|
||||
|
||||
op.drop_constraint('ck_conferences_summary_recipients', 'conferences', type_='check')
|
||||
op.drop_column('conferences', 'ics_sequence')
|
||||
op.drop_column('conferences', 'summary_recipients')
|
||||
|
||||
op.drop_index('ix_email_deliveries_conference', table_name='email_deliveries')
|
||||
op.drop_index('uq_email_deliveries_summary', table_name='email_deliveries')
|
||||
op.drop_table('email_deliveries')
|
||||
|
||||
op.drop_table('instance_settings')
|
||||
@@ -0,0 +1,109 @@
|
||||
"""session_audio_tracks + атрибуция phrases к участнику (ADR-002)
|
||||
|
||||
Схема БД для записи аудиодорожек сеанса и атрибуции фраз:
|
||||
- новая таблица `session_audio_tracks` — одна аудиодорожка сеанса, записанная
|
||||
LiveKit Track Egress (per-track, трек = спикер, диаризация не нужна);
|
||||
- `phrases`: `user_id` -> `participant_id` (FK `conference_participants.id`,
|
||||
ON DELETE CASCADE) — атрибуция фразы к окну присутствия участника сеанса
|
||||
(пользователя ИЛИ гостя), а не напрямую к `users`
|
||||
(`docs/architecture/adr/002-phrase-attribution-session-participant.md`).
|
||||
|
||||
Продакшен-данных нет (см. ADR-002, контекст) — простая замена колонки без
|
||||
backfill; единственная строка `phrases`, оставшаяся в дев-БД от ручного
|
||||
тестирования, удаляется явно (см. `_clear_dev_phrases`), т.к. её
|
||||
`user_id` не сопоставим ни с одним `conference_participants.id`. Downgrade
|
||||
симметричен и данные `phrases` не восстанавливает (как и в f418dd65e7b1).
|
||||
|
||||
Revision ID: 88aa676ac140
|
||||
Revises: f418dd65e7b1
|
||||
Create Date: 2026-07-17 10:30:10.456704
|
||||
|
||||
"""
|
||||
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 = '88aa676ac140'
|
||||
down_revision: Union[str, Sequence[str], None] = 'f418dd65e7b1'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _clear_dev_phrases(connection: Connection) -> None:
|
||||
"""Удалить строки `phrases`, оставшиеся от ручного тестирования до ADR-002.
|
||||
|
||||
Продакшен-данных нет (см. докстринг ревизии) — на пустой таблице это no-op.
|
||||
"""
|
||||
connection.execute(sa.text("DELETE FROM phrases"))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
bind = op.get_bind()
|
||||
|
||||
op.create_table(
|
||||
'session_audio_tracks',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('session_id', sa.UUID(), nullable=False),
|
||||
sa.Column('participant_id', sa.UUID(), nullable=False),
|
||||
sa.Column('track_sid', sa.String(length=64), nullable=False),
|
||||
sa.Column('egress_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('file_path', sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
'status',
|
||||
sa.Enum('recording', 'recorded', 'transcribed', 'failed', name='audio_track_status'),
|
||||
server_default='recording',
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('ended_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('segments', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
['participant_id'], ['conference_participants.id'], ondelete='CASCADE'
|
||||
),
|
||||
sa.ForeignKeyConstraint(['session_id'], ['conference_sessions.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('session_id', 'track_sid', name='uq_session_track'),
|
||||
)
|
||||
op.create_index(
|
||||
'ix_session_audio_tracks_session_id', 'session_audio_tracks', ['session_id']
|
||||
)
|
||||
op.create_index(
|
||||
'ix_session_audio_tracks_session_id_status',
|
||||
'session_audio_tracks',
|
||||
['session_id', 'status'],
|
||||
)
|
||||
|
||||
_clear_dev_phrases(bind)
|
||||
op.drop_constraint('phrases_user_id_fkey', 'phrases', type_='foreignkey')
|
||||
op.drop_column('phrases', 'user_id')
|
||||
op.add_column('phrases', sa.Column('participant_id', sa.UUID(), nullable=False))
|
||||
op.create_foreign_key(
|
||||
'phrases_participant_id_fkey',
|
||||
'phrases', 'conference_participants',
|
||||
['participant_id'], ['id'], ondelete='CASCADE',
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema.
|
||||
|
||||
Разрушительная миграция для `phrases` (см. докстринг ревизии выше) —
|
||||
downgrade восстанавливает структуру колонки, но не исходные данные.
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
|
||||
_clear_dev_phrases(bind)
|
||||
op.drop_constraint('phrases_participant_id_fkey', 'phrases', type_='foreignkey')
|
||||
op.drop_column('phrases', 'participant_id')
|
||||
op.add_column('phrases', sa.Column('user_id', sa.UUID(), nullable=False))
|
||||
op.create_foreign_key('phrases_user_id_fkey', 'phrases', 'users', ['user_id'], ['id'])
|
||||
|
||||
op.drop_index('ix_session_audio_tracks_session_id_status', table_name='session_audio_tracks')
|
||||
op.drop_index('ix_session_audio_tracks_session_id', table_name='session_audio_tracks')
|
||||
op.drop_table('session_audio_tracks')
|
||||
postgresql.ENUM(name='audio_track_status').drop(bind, checkfirst=True)
|
||||
49
backend/alembic/versions/9d37822e4513_teams.py
Normal file
49
backend/alembic/versions/9d37822e4513_teams.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""teams
|
||||
|
||||
Справочник команд и привязка пользователя к команде:
|
||||
- `teams` — id/name (уникально)/created_at;
|
||||
- `users.team_id` — необязательная ссылка на команду, `ON DELETE SET NULL`
|
||||
(удаление команды не удаляет пользователей, только снимает привязку).
|
||||
|
||||
Revision ID: 9d37822e4513
|
||||
Revises: 5970bf64fc43
|
||||
Create Date: 2026-07-18 03:00:27.636900
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9d37822e4513'
|
||||
down_revision: Union[str, Sequence[str], None] = '5970bf64fc43'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table(
|
||||
'teams',
|
||||
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(
|
||||
'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name'),
|
||||
)
|
||||
op.add_column('users', sa.Column('team_id', sa.UUID(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
'fk_users_team_id_teams', 'users', 'teams', ['team_id'], ['id'], ondelete='SET NULL'
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_constraint('fk_users_team_id_teams', 'users', type_='foreignkey')
|
||||
op.drop_column('users', 'team_id')
|
||||
op.drop_table('teams')
|
||||
@@ -0,0 +1,76 @@
|
||||
"""conference invitees and avatars
|
||||
|
||||
Реализует раздел «Модель данных» ADR-003
|
||||
(`docs/architecture/adr/003-conference-invitees.md`):
|
||||
- новая таблица `conference_invitees` — приглашённые НА КОНФЕРЕНЦИЮ
|
||||
(зарегистрированный `user_id` ИЛИ внешний `email`, ровно одна identity);
|
||||
организатор в таблице не хранится (выводится из `conferences.owner_id`);
|
||||
- `users.avatar_path` — путь к загруженному аватару,
|
||||
`NULL` — заглушка с инициалами на фронте.
|
||||
|
||||
Revision ID: d87681e12784
|
||||
Revises: 504791847d4f
|
||||
Create Date: 2026-07-19 12:03:06.294730
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd87681e12784'
|
||||
down_revision: Union[str, Sequence[str], None] = '504791847d4f'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table(
|
||||
'conference_invitees',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('conference_id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=True),
|
||||
sa.Column('email', sa.String(length=255), nullable=True),
|
||||
sa.Column(
|
||||
'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
'(user_id IS NOT NULL)::int + (email IS NOT NULL)::int = 1',
|
||||
name='ck_conference_invitees_single_identity',
|
||||
),
|
||||
sa.ForeignKeyConstraint(['conference_id'], ['conferences.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
# Частичные уникальные индексы (ADR-003, п.1): дубль по зарегистрированному
|
||||
# пользователю или по email (регистронезависимо — `lower(email)`, email
|
||||
# приложение хранит уже в lower-case, индекс — доп. страховка).
|
||||
op.create_index(
|
||||
'uq_conference_invitees_user',
|
||||
'conference_invitees',
|
||||
['conference_id', 'user_id'],
|
||||
unique=True,
|
||||
postgresql_where=sa.text('user_id IS NOT NULL'),
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX uq_conference_invitees_email
|
||||
ON conference_invitees (conference_id, lower(email))
|
||||
WHERE email IS NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
op.add_column('users', sa.Column('avatar_path', sa.String(length=512), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_column('users', 'avatar_path')
|
||||
|
||||
op.execute('DROP INDEX IF EXISTS uq_conference_invitees_email')
|
||||
op.drop_index('uq_conference_invitees_user', table_name='conference_invitees')
|
||||
op.drop_table('conference_invitees')
|
||||
366
backend/alembic/versions/f418dd65e7b1_dynamic_conferences.py
Normal file
366
backend/alembic/versions/f418dd65e7b1_dynamic_conferences.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""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')
|
||||
Reference in New Issue
Block a user