Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View File

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

View 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)

View File

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

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

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

View File

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

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

View File

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

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