Первоначальная версия 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,39 @@
"""Пакет ORM-моделей.
Импортирует все модульные модели, чтобы `Base.metadata` был полностью
заполнен для автогенерации Alembic.
"""
from models.audio_track import SessionAudioTrack
from models.base import Base
from models.chat import ChatMessage
from models.conference import Conference
from models.email_delivery import EmailDelivery
from models.email_verification import EmailVerificationToken
from models.guest import GuestAccess
from models.instance_setting import InstanceSetting
from models.invitee import ConferenceInvitee
from models.participant import ConferenceParticipant
from models.phrase import Phrase
from models.session import ConferenceSession
from models.team import Team
from models.user import User
from models.webhook_event import LivekitWebhookEvent
__all__ = [
"Base",
"ChatMessage",
"Conference",
"ConferenceInvitee",
"ConferenceParticipant",
"ConferenceSession",
"EmailDelivery",
"EmailVerificationToken",
"GuestAccess",
"InstanceSetting",
"LivekitWebhookEvent",
"Phrase",
"SessionAudioTrack",
"Team",
"User",
]

View File

@@ -0,0 +1,65 @@
"""Модель SessionAudioTrack — записанный per-track аудиотрек участника сеанса.
Каждая строка соответствует одной аудио-дорожке (микрофону) одного участника
одного сеанса, записываемой отдельным LiveKit Track Egress. Диаризация не
нужна: трек = спикер, атрибуция — к `conference_participants` (ADR-002).
"""
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, Enum, ForeignKey, Index, String, Text, UniqueConstraint, text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from models.base import Base
AudioTrackStatus = Enum(
"recording",
"recorded",
"transcribed",
"failed",
name="audio_track_status",
)
class SessionAudioTrack(Base):
"""Одна аудиодорожка сеанса, записанная LiveKit Track Egress в отдельный файл.
`status` — состояние per-track шага пайплайна (независимо от
`conference_sessions.pipeline_status`):
`recording` (egress запущен) -> `recorded` (файл финализирован по
`egress_ended`) -> `transcribed` (сегменты Whisper сохранены в `segments`)
либо `failed` на любом шаге.
"""
__tablename__ = "session_audio_tracks"
__table_args__ = (
UniqueConstraint("session_id", "track_sid", name="uq_session_track"),
Index("ix_session_audio_tracks_session_id", "session_id"),
Index("ix_session_audio_tracks_session_id_status", "session_id", "status"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
)
session_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("conference_sessions.id", ondelete="CASCADE"),
nullable=False,
)
participant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("conference_participants.id", ondelete="CASCADE"),
nullable=False,
)
track_sid: Mapped[str] = mapped_column(String(64), nullable=False)
egress_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
file_path: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
AudioTrackStatus, nullable=False, server_default="recording"
)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
segments: Mapped[list[dict[str, Any]] | None] = mapped_column(JSONB, nullable=True)

7
backend/models/base.py Normal file
View File

@@ -0,0 +1,7 @@
"""Декларативная база для всех ORM моделей."""
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
"""Общая декларативная база; все модели наследуют от этого."""

60
backend/models/chat.py Normal file
View File

@@ -0,0 +1,60 @@
"""Модель сообщения чата — текстовый чат в конференции (WS).
Автор — зарегистрированный пользователь ИЛИ гость (ровно один из `user_id`/
`guest_access_id` заполнен, аналогично `ConferenceParticipant` из ADR-001,
п.6). `author_name` — снапшот отображаемого имени из LiveKit access-токена
на момент отправки сообщения: не связан жёстко с текущим именем
пользователя/гостя, переживает их последующее переименование или удаление
гостевой записи (`ON DELETE CASCADE` у `guest_access_id` относится к самому
сообщению, а не к отображаемому имени в истории чата).
"""
import uuid
from datetime import datetime
from sqlalchemy import (
BigInteger,
CheckConstraint,
DateTime,
ForeignKey,
Identity,
Index,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from models.base import Base
class ChatMessage(Base):
"""Одно сообщение чата, опубликованное в конференции."""
__tablename__ = "chat_messages"
__table_args__ = (
Index("ix_chat_messages_session_id_created_at", "session_id", "created_at"),
CheckConstraint(
"user_id IS NOT NULL OR guest_access_id IS NOT NULL",
name="ck_chat_messages_author",
),
)
id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True)
session_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("conference_sessions.id", ondelete="CASCADE"),
nullable=False,
)
user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
guest_access_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("guest_access.id", ondelete="CASCADE"), nullable=True
)
author_name: Mapped[str] = mapped_column(String(255), nullable=False)
text: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)

View File

@@ -0,0 +1,89 @@
"""Модель Conference — конференция как пользовательская сущность (ADR-001).
Конференция — постоянная сущность с номером и ссылкой; каждый её запуск
порождает отдельную запись `ConferenceSession` (см. `models/session.py`),
которая и является единицей AI-пайплайна пост-обработки.
"""
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import (
Boolean,
CheckConstraint,
DateTime,
Enum,
ForeignKey,
Integer,
String,
Text,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from models.base import Base
# Жизненный цикл конференции (ADR-001, п.2): `draft` не нужен — создание
# атомарно из формы; `is_pinned` — ортогональный флаг, а не статус.
ConferenceStatus = Enum(
"scheduled",
"active",
"ended",
name="conference_status",
)
class Conference(Base):
"""Конференция — номер, постоянная ссылка, владелец, признаки закрепления/закрытости."""
__tablename__ = "conferences"
__table_args__ = (
CheckConstraint(
"is_closed = false OR password_hash IS NOT NULL",
name="ck_conferences_closed_requires_password",
),
CheckConstraint(
"recurrence IS NULL OR is_pinned = true",
name="ck_conferences_recurrence_requires_pinned",
),
CheckConstraint(
"summary_recipients IN ('all', 'owner')",
name="ck_conferences_summary_recipients",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
)
# 9 десятичных цифр, первая 1..9 (services/conference_ids.py::generate_number).
number: Mapped[str] = mapped_column(String(9), unique=True, nullable=False)
# secrets.token_urlsafe(8) — 11 base64url-символов (services/conference_ids.py::generate_slug).
slug: Mapped[str] = mapped_column(String(22), unique=True, nullable=False)
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
owner_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
status: Mapped[str] = mapped_column(
ConferenceStatus, nullable=False, server_default="scheduled"
)
is_pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
is_closed: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
scheduled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Pydantic-модель RecurrenceRule (services/recurrence.py), сериализованная в JSONB.
recurrence: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Переопределение рассылки саммари конкретной конференции;
# NULL = использовать дефолт инстанса (`instance_settings['summary_recipients']`).
summary_recipients: Mapped[str | None] = mapped_column(String(16), nullable=True)
# Счётчик изменений расписания для VEVENT `SEQUENCE` (.ics):
# растёт при правке `scheduled_at`/`duration_minutes`/`recurrence`/`title`,
# чтобы календарные клиенты обновляли уже принятое приглашение по UID.
ics_sequence: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)

View File

@@ -0,0 +1,59 @@
"""Модель EmailDelivery — факт отправки письма.
Идемпотентность рассылки саммари (`kind='summary'`): уникальный частичный
индекс `(session_id, recipient_email)` не даёт повторной отправке того же
письма тому же адресату. Приглашения (`kind='invitation'`) — только журнал,
без unique (изменение расписания обязано переслать обновление).
"""
import uuid
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, func, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from models.base import Base
class EmailDelivery(Base):
"""Запись об отправленном письме: саммари (`session_id`) либо приглашение (`conference_id`)."""
__tablename__ = "email_deliveries"
__table_args__ = (
CheckConstraint("kind IN ('summary', 'invitation')", name="ck_email_deliveries_kind"),
CheckConstraint(
"(kind = 'summary') = (session_id IS NOT NULL)",
name="ck_email_deliveries_summary_has_session",
),
CheckConstraint(
"(kind = 'invitation') = (conference_id IS NOT NULL)",
name="ck_email_deliveries_invitation_has_conference",
),
Index(
"uq_email_deliveries_summary",
"session_id",
"recipient_email",
unique=True,
postgresql_where=text("kind = 'summary'"),
),
Index("ix_email_deliveries_conference", "conference_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
)
session_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("conference_sessions.id", ondelete="CASCADE"),
nullable=True,
)
conference_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("conferences.id", ondelete="CASCADE"), nullable=True
)
# Хранится в lower() (нормализация — на уровне сервиса-отправителя).
recipient_email: Mapped[str] = mapped_column(String(320), nullable=False)
kind: Mapped[str] = mapped_column(String(16), nullable=False)
sent_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)

View File

@@ -0,0 +1,30 @@
"""Модель токена подтверждения email пользователя."""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Text, func, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from models.base import Base
class EmailVerificationToken(Base):
"""Одноразовый токен подтверждения email; хранится только sha256-хэш токена."""
__tablename__ = "email_verification_tokens"
__table_args__ = (Index("ix_email_verification_tokens_user_id", "user_id"),)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)

28
backend/models/guest.py Normal file
View File

@@ -0,0 +1,28 @@
"""Модель GuestAccess — гость, представившийся при входе в конференцию (ADR-001, п.6)."""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from models.base import Base
class GuestAccess(Base):
"""Запись о госте: имя обязательно, email — факультативен (для рассылки саммари)."""
__tablename__ = "guest_access"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
)
conference_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("conferences.id", ondelete="CASCADE"), nullable=False
)
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)

View File

@@ -0,0 +1,27 @@
"""Модель InstanceSetting — key-value настройки инстанса (JSONB).
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
`ai_level`, `summary_recipients`, `display_timezone`) — новая настройка не
требует миграции, только запись новой строки (см. `services/instance_settings.py`).
"""
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from models.base import Base
class InstanceSetting(Base):
"""Одна настройка инстанса: `key` — имя секции, `value` — JSONB-значение."""
__tablename__ = "instance_settings"
key: Mapped[str] = mapped_column(String, primary_key=True)
value: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)

61
backend/models/invitee.py Normal file
View File

@@ -0,0 +1,61 @@
"""Модель ConferenceInvitee — приглашённый на конференцию (ADR-003).
Не путать с `ConferenceParticipant` (ADR-002) — та таблица фиксирует
ФАКТИЧЕСКОЕ присутствие в сеансе, эта — состав, заданный организатором ДО
конференции. Организатор в этой таблице не хранится: он всегда выводится из
`conferences.owner_id` (см. ADR-003, п.2) — инвариант «организатор всегда в
составе и неудаляем» обеспечен конструктивно, без триггеров.
"""
import uuid
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, func, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from models.base import Base
class ConferenceInvitee(Base):
"""Приглашённый: ровно одна identity — зарегистрированный `user_id` ИЛИ внешний `email`."""
__tablename__ = "conference_invitees"
__table_args__ = (
CheckConstraint(
"(user_id IS NOT NULL)::int + (email IS NOT NULL)::int = 1",
name="ck_conference_invitees_single_identity",
),
# Частичные уникальные индексы (ADR-003, п.1): без дублей приглашения
# одного и того же пользователя/email на одну конференцию.
# `lower(email)` — регистронезависимо (email нормализуется в lower-case
# уже на уровне схемы `InviteeIn`, индекс — доп. страховка).
Index(
"uq_conference_invitees_user",
"conference_id",
"user_id",
unique=True,
postgresql_where=text("user_id IS NOT NULL"),
),
Index(
"uq_conference_invitees_email",
"conference_id",
text("lower(email)"),
unique=True,
postgresql_where=text("email IS NOT NULL"),
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
)
conference_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("conferences.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)

View File

@@ -0,0 +1,44 @@
"""Модель участника сеанса конференции — метки времени присутствия и identity (ADR-001, п.6)."""
import uuid
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from models.base import Base
class ConferenceParticipant(Base):
"""Временное окно присутствия участника (пользователя ИЛИ гостя) в сеансе.
Ровно одно из `user_id`/`guest_id` заполнено (CHECK) — зарегистрированный
участник и гость взаимоисключающие identity одной строки.
"""
__tablename__ = "conference_participants"
__table_args__ = (
Index("ix_conference_participants_session_id", "session_id"),
CheckConstraint(
"(user_id IS NOT NULL)::int + (guest_id IS NOT NULL)::int = 1",
name="ck_conference_participants_exactly_one_identity",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
)
session_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("conference_sessions.id", ondelete="CASCADE"),
nullable=False,
)
user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
guest_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("guest_access.id"), nullable=True
)
joined_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
left_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

36
backend/models/phrase.py Normal file
View File

@@ -0,0 +1,36 @@
"""Модель Phrase — фраза транскрибации, атрибутированная к участнику сеанса (ADR-002)."""
import uuid
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Identity, Index, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from models.base import Base
class Phrase(Base):
"""Одна восстановленная фраза, относящаяся к транскрибации конференции.
Атрибуция — к `conference_participants` (ADR-002), а не к `users`: гость
без `user_id` проходит пайплайн наравне с зарегистрированным пользователем.
"""
__tablename__ = "phrases"
__table_args__ = (Index("ix_phrases_session_id_t_start", "session_id", "t_start"),)
id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True)
participant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("conference_participants.id", ondelete="CASCADE"),
nullable=False,
)
session_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("conference_sessions.id", ondelete="CASCADE"),
nullable=False,
)
data: Mapped[str] = mapped_column(Text, nullable=False)
t_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
t_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)

55
backend/models/session.py Normal file
View File

@@ -0,0 +1,55 @@
"""Модель ConferenceSession — один запуск конференции, единица AI-пайплайна (ADR-001).
Таблица `conference_sessions` исторически получена переименованием старой
`conferences` — семантика `pipeline_status` (идемпотентная статус-машина
пост-обработки) при этом не менялась.
"""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, Index, String, Text, func, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from models.base import Base
PIPELINE_STATUSES: tuple[str, ...] = (
"recording",
"transcribing",
"summarizing",
"notified",
"failed",
)
"""Полный перечень статусов пайплайна — переиспользуется метриками Prometheus
(`backend/api/metrics.py`), чтобы gauge по статусам не
"пропадал" из `/metrics` для статусов без единого сеанса на момент scrape."""
PipelineStatus = Enum(*PIPELINE_STATUSES, name="pipeline_status")
class ConferenceSession(Base):
"""Один сеанс конференции и состояние его pipeline пост-обработки."""
__tablename__ = "conference_sessions"
__table_args__ = (
Index("ix_conference_sessions_conference_id_t_start", "conference_id", "t_start"),
Index("ix_conference_sessions_pipeline_status", "pipeline_status"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
)
conference_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("conferences.id", ondelete="CASCADE"), nullable=False
)
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
t_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
t_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
summary_data: Mapped[str | None] = mapped_column(Text, nullable=True)
pipeline_status: Mapped[str] = mapped_column(
PipelineStatus, nullable=False, server_default="recording"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)

24
backend/models/team.py Normal file
View File

@@ -0,0 +1,24 @@
"""Модель Team — справочник команд для группировки пользователей."""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String, func, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from models.base import Base
class Team(Base):
"""Команда (справочник); привязка пользователя — `users.team_id` (SET NULL при удалении)."""
__tablename__ = "teams"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
)
name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)

40
backend/models/user.py Normal file
View File

@@ -0,0 +1,40 @@
"""Модель User."""
import uuid
from datetime import datetime
from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, String, Text, func, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from models.base import Base
class User(Base):
"""Зарегистрированный пользователь приложения."""
__tablename__ = "users"
__table_args__ = (CheckConstraint("role IN ('admin', 'user')", name="ck_users_role"),)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
name_user: Mapped[str] = mapped_column(String(255), nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
role: Mapped[str] = mapped_column(String(16), nullable=False, server_default="user")
email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
# Блокировка администратором: проверяется в `api/deps.py::_user_from_token`,
# действует немедленно — до истечения уже выданного access-токена не ждём.
is_blocked: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
# Привязка к команде (справочник `teams`):
# необязательная, при удалении команды обнуляется (ON DELETE SET NULL).
team_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("teams.id", ondelete="SET NULL"), nullable=True
)
# Путь к загруженному аватару (относительно `MEDIA_ROOT`):
# `avatars/{user_id}.{ext}`; `NULL` — заглушка с инициалами на фронте.
avatar_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)

View File

@@ -0,0 +1,25 @@
"""Модель для идемпотентной обработки webhook-событий LiveKit."""
from datetime import datetime
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from models.base import Base
class LivekitWebhookEvent(Base):
"""Журнал полученных webhook-событий LiveKit.
`event_id` — первичный ключ; повторная доставка того же события
(`INSERT ... ON CONFLICT DO NOTHING`) отклоняется на уровне БД в одной
транзакции с эффектами обработчика (см. `api/livekit_webhook.py`).
"""
__tablename__ = "livekit_webhook_events"
event_id: Mapped[str] = mapped_column(String(255), primary_key=True)
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
received_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)