Первоначальная версия VidConf
This commit is contained in:
39
backend/models/__init__.py
Normal file
39
backend/models/__init__.py
Normal 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",
|
||||
]
|
||||
65
backend/models/audio_track.py
Normal file
65
backend/models/audio_track.py
Normal 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
7
backend/models/base.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Декларативная база для всех ORM моделей."""
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Общая декларативная база; все модели наследуют от этого."""
|
||||
60
backend/models/chat.py
Normal file
60
backend/models/chat.py
Normal 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()
|
||||
)
|
||||
89
backend/models/conference.py
Normal file
89
backend/models/conference.py
Normal 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()
|
||||
)
|
||||
59
backend/models/email_delivery.py
Normal file
59
backend/models/email_delivery.py
Normal 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()
|
||||
)
|
||||
30
backend/models/email_verification.py
Normal file
30
backend/models/email_verification.py
Normal 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
28
backend/models/guest.py
Normal 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()
|
||||
)
|
||||
27
backend/models/instance_setting.py
Normal file
27
backend/models/instance_setting.py
Normal 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
61
backend/models/invitee.py
Normal 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()
|
||||
)
|
||||
44
backend/models/participant.py
Normal file
44
backend/models/participant.py
Normal 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
36
backend/models/phrase.py
Normal 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
55
backend/models/session.py
Normal 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
24
backend/models/team.py
Normal 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
40
backend/models/user.py
Normal 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()
|
||||
)
|
||||
25
backend/models/webhook_event.py
Normal file
25
backend/models/webhook_event.py
Normal 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()
|
||||
)
|
||||
Reference in New Issue
Block a user