Первоначальная версия 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

1
backend/.python-version Normal file
View File

@@ -0,0 +1 @@
3.12

40
backend/Dockerfile Normal file
View File

@@ -0,0 +1,40 @@
FROM python:3.12-slim AS base
# uv binary, pinned via digest-less tag (see astral-sh/uv releases)
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
PYTHONUNBUFFERED=1
# Экстра-группа `gpu` (ADR-004: cuBLAS/cuDNN9 для FasterWhisperGPU,
# `core/plugins/faster_whisper.py`) — ставится ТОЛЬКО в GPU-образе воркера
# транскрибации (deploy/docker-compose.yml, сервис `worker-transcriber-gpu`,
# `build.args.WITH_GPU_EXTRA: "true"`, профиль `transcribe-gpu`); базовый
# CPU-образ backend/worker/worker-transcriber собирается с дефолтом "false"
# — не тянет нативные CUDA-библиотеки туда, где GPU нет.
ARG WITH_GPU_EXTRA=false
# Install dependencies first (better layer caching), then copy source.
COPY pyproject.toml uv.lock ./
RUN if [ "$WITH_GPU_EXTRA" = "true" ]; then \
uv sync --frozen --no-dev --no-install-project --extra gpu; \
else \
uv sync --frozen --no-dev --no-install-project; \
fi
COPY . .
RUN if [ "$WITH_GPU_EXTRA" = "true" ]; then \
uv sync --frozen --no-dev --extra gpu; \
else \
uv sync --frozen --no-dev; \
fi
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=5s --retries=10 --start-period=15s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1
CMD ["uv", "run", "uvicorn", "main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]

356
backend/README.md Normal file
View File

@@ -0,0 +1,356 @@
# Backend — FastAPI приложение
HTTP API сервер для VidConf: аутентификация (JWT), динамические конференции
(создание, календарь, вход по ссылке/номеру, гостевой доступ), чат в
реальном времени, администрирование (конференции/пользователи/команды/
настройки инстанса), контракты плагинов (Transcriber/Summarizer) и интеграция
с LiveKit (токены, webhook-приёмник).
## Структура
```
backend/
├── api/ HTTP endpoint'ы
│ ├── __init__.py
│ ├── deps.py JWT-зависимости для аутентификации
│ ├── auth.py Регистрация, подтверждение email, вход, refresh, выход
│ ├── users.py Профиль, аватары, поиск пользователей (GET/PATCH /users/me,
│ │ POST/DELETE /users/me/avatar, POST /users/me/password, GET /users?q=)
│ ├── teams.py Справочник команд (GET /teams)
│ ├── admin.py Администрирование конференций, пользователей, команд, настроек инстанса
│ ├── health.py GET /api/health
│ ├── metrics.py GET /metrics (Prometheus)
│ ├── conferences.py Динамические конференции (create, my, calendar, resolve,
│ │ join, guest-join, GET/PATCH/DELETE /{id})
│ ├── chat.py WS-эндпоинт чата (auth по LiveKit-токену, история, broadcast через Redis pub/sub)
│ └── livekit_webhook.py Webhook-приёмник событий LiveKit
├── core/ Основные модули
│ ├── config.py Конфиг из .env (Settings)
│ ├── security.py Hash/verify пароля (argon2), JWT токены
│ ├── db.py Управление сеансами БД (async)
│ ├── redis.py Redis клиент
│ ├── rate_limit.py Rate limiting для публичных endpoint'ов
│ ├── summarization/ Чанкинг транскрипта, LLM-клиент, подсчёт токенов
│ └── plugins/ Контракты Transcriber/Summarizer, factory, реализации
│ ├── transcriber.py Контракт Transcriber
│ ├── summarizer.py Контракт Summarizer
│ ├── config.py Pydantic-конфиг плагинов
│ ├── factory.py Регистрация и создание провайдеров
│ ├── null.py NullTranscriber, NullSummarizer (no-op)
│ ├── faster_whisper.py FasterWhisperCPU, FasterWhisperGPU
│ └── qwen_local.py QwenLocal (map-reduce суммаризация через llama.cpp)
├── models/ SQLAlchemy ORM (14 таблиц, см. docs/db/schema.md)
│ ├── base.py Base class
│ ├── user.py User
│ ├── team.py Team (справочник команд)
│ ├── email_verification.py EmailVerificationToken
│ ├── conference.py Conference (номер, slug, владелец, recurrence)
│ ├── invitee.py ConferenceInvitee (приглашённые: user_id ИЛИ email)
│ ├── guest.py GuestAccess (display_name, email)
│ ├── session.py ConferenceSession (один запуск конференции, pipeline_status)
│ ├── participant.py ConferenceParticipant (user_id ИЛИ guest_id, session_id)
│ ├── audio_track.py SessionAudioTrack (аудиодорожка участника)
│ ├── phrase.py Phrase (текстовые сегменты транскрибации, participant_id, session_id)
│ ├── chat.py ChatMessage (автор: пользователь ИЛИ гость, author_name, session_id)
│ ├── email_delivery.py EmailDelivery (журнал отправленных писем)
│ ├── instance_setting.py InstanceSetting (key-value настройки инстанса, JSONB)
│ └── webhook_event.py LivekitWebhookEvent
├── repositories/ Async слой доступа к данным
│ ├── users.py
│ ├── conferences.py
│ ├── chat.py ChatMessageRepository (последние N сообщений, добавление)
│ └── admin.py
├── services/ Бизнес-логика
│ ├── auth.py Аутентификация (регистрация, вход)
│ ├── conferences.py CRUD и валидация конференций, жизненный цикл, участники, .ics-приглашения
│ ├── conference_access.py Проверка доступа (пароль, статус ended)
│ ├── conference_ids.py Генерация номера (9 цифр) и slug (base64url)
│ ├── recurrence.py RecurrenceRule, развёртка occurrences
│ ├── avatars.py Загрузка, валидация (magic bytes), удаление аватаров
│ ├── profile.py Обновление профиля (ФИО, команда)
│ ├── invitations_producer.py Постановка .ics-приглашений в очередь Celery
│ ├── chat.py ChatService (auth по LiveKit-токену, history, persist+publish)
│ ├── livekit_tokens.py Генерация LiveKit JWT-токенов
│ ├── webhook_handlers.py Обработчики webhook-событий от LiveKit
│ ├── egress.py Запуск/остановка записи аудио через LiveKit Egress
│ ├── email.py / email_templates.py Отправка писем (саммари, приглашения)
│ ├── ics.py Генерация .ics-приглашений
│ ├── instance_settings.py Эффективная конфигурация инстанса (уровень AI, чат и т.д.)
│ ├── ai_levels.py / ai_tiers.py Матрица уровней AI (min/medium/max), детект доступности
│ └── pipeline_producer.py Постановка задач пайплайна пост-обработки в Celery
├── schemas/ Pydantic-схемы для запросов/ответов
│ ├── auth.py Auth, профиль пользователя
│ ├── admin.py Админка (конференции, пользователи, команды, настройки)
│ ├── conferences.py ConferenceCreateIn/UpdateIn, ConferenceOut, JoinOut, ResolveOut, OccurrenceOut, InviteeIn/Out
│ └── chat.py ChatAuthIn, ChatMessageIn/Out, ChatHistoryOut, ChatErrorOut
├── alembic/ Миграции БД
│ ├── versions/ 9 миграций от initial schema до teams/avatars/settings
│ └── env.py
├── scripts/ Утилиты
│ ├── __init__.py
│ ├── seed.py Идемпотентный сид: единственный админ-пользователь
│ └── apply_preset_settings.py Применить настройки инстанса под пресет install.sh
├── tests/ Тесты (pytest, см. полный список файлов в каталоге)
│ ├── conftest.py
│ ├── test_health.py
│ ├── test_auth.py / test_rbac.py / test_tokens.py
│ ├── test_conferences_api.py / test_conference_service.py / test_recurrence.py
│ ├── test_chat_ws.py
│ ├── test_admin_api.py / test_admin_teams.py / test_teams_api.py / test_users_api.py
│ ├── test_plugins_factory.py / test_qwen_local.py / test_llm_client.py
│ ├── test_pipeline.py / test_build_phrases.py / test_summarize_task.py / test_notify_task.py
│ └── ...
├── main.py Точка входа FastAPI приложения
└── README.md Этот файл
```
## Быстрый старт
### Требования
- Python 3.12+
- `uv`: `brew install uv`
- PostgreSQL 16 + Redis (через docker-compose или локально)
### 1. Установка зависимостей
```bash
cd backend
uv sync
```
### 2. Запуск миграций
```bash
uv run alembic upgrade head
```
Создаёт 14 таблиц и включает расширение PostgreSQL `btree_gist` (установлено, но
на текущей схеме не используется ни одним constraint'ом).
### 3. Загрузка тестовых данных (опционально)
```bash
uv run python -m scripts.seed
```
Идемпотентный сид: создаёт единственного администратора (`SEED_ADMIN_EMAIL` /
`SEED_ADMIN_PASSWORD`), если его ещё нет. Конференции создаются пользователями
динамически, предустановленных данных не требуется.
### 4. Запуск сервера
```bash
uv run uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
API доступен по адресу `http://localhost:8000`.
Документация:
- Swagger: `http://localhost:8000/docs`
- ReDoc: `http://localhost:8000/redoc`
## Тестирование
```bash
# Запуск всех тестов
uv run pytest -q
# Запуск с покрытием
uv run pytest --cov=. --cov-report=html
# Запуск конкретного теста
uv run pytest tests/test_health.py -v
```
## Качество кода
```bash
# Linting
uv run ruff check .
# Проверка форматирования
uv run ruff format --check .
# Автоматическое форматирование
uv run ruff format .
# Проверка типов
uv run mypy .
```
Все проверки должны пройти перед мёржем.
## Конфигурация
### Переменные окружения (`.env`)
Полный список переменных (БД, Redis, JWT, LiveKit, Coturn, email/SMTP, уровни
AI, обнаруженное железо для install.sh и т.д.) и их назначение — см.
[docs/deploy/env.md](../docs/deploy/env.md). Модель `Settings` в
`core/config.py` — единственный источник дефолтов.
### Конфигурация плагинов (`config/plugins.yaml`)
```yaml
transcriber:
enabled: true
provider: "null"
model: null
language: ru
summarizer:
enabled: true
provider: "null"
model: null
chunk_minutes: 20
chat:
enabled: true
```
Подробнее о плагинах см. [docs/plugins/contracts.md](../docs/plugins/contracts.md).
## База данных
### Схема
**14 ORM моделей:**
- `users` — зарегистрированные пользователи
- `teams` — справочник команд
- `email_verification_tokens` — одноразовые токены верификации email
- `conferences` — постоянные сущности конференций (номер, slug, владелец, recurrence)
- `conference_invitees` — приглашённые на конференцию (пользователь или внешний email)
- `guest_access` — гости, представившиеся при входе (display_name, email)
- `conference_sessions` — один запуск конференции (pipeline_status, summary_data)
- `conference_participants` — отслеживание присутствия в сеансе (user_id ИЛИ guest_id, session_id)
- `session_audio_tracks` — аудиодорожки участников сеанса
- `phrases` — текстовые сегменты транскрибации (participant_id, session_id)
- `chat_messages` — сообщения в сеансе (session_id, автор — пользователь или гость)
- `email_deliveries` — журнал отправленных писем
- `instance_settings` — key-value настройки инстанса (JSONB)
- `livekit_webhook_events` — журнал webhook-событий для идемпотентности
Полную ER-диаграмму и обоснования дизайна см. [docs/db/schema.md](../docs/db/schema.md).
### Миграции
Alembic управляет схемой:
```bash
# Проверить текущую версию
uv run alembic current
# Обновить до последней версии
uv run alembic upgrade head
# Создать миграцию после изменений модели
uv run alembic revision --autogenerate -m "description"
```
**Правило:** Модель + миграция коммитятся вместе; никогда только модель.
### Временные метки
Все `DateTime(timezone=True)` сохраняются как UTC в PostgreSQL. Клиент преобразует в локальный часовой пояс.
## API
Полная спецификация: [docs/api/README.md](../docs/api/README.md).
- `GET /api/health` — проверка здоровья (БД, Redis)
- `GET /metrics` — метрики Prometheus
- `/api/v1/auth/*` — регистрация, подтверждение email, вход, refresh, выход
- `/api/v1/users/*` — профиль, аватар, смена пароля, поиск пользователей
- `/api/v1/teams` — справочник команд
- `/api/v1/conferences/*` — создание, календарь, вход по ссылке/номеру, гостевой вход, изменение/удаление
- `WS /api/v1/conferences/{id}/chat` — текстовый чат в реальном времени
- `/api/v1/admin/*` — администрирование конференций, пользователей, команд, настроек
- `/api/v1/livekit/webhook` — приёмник webhook-событий LiveKit
## Плагины
Контракты плагинов в `backend/core/plugins/`:
**Transcriber:**
```python
class Transcriber(ABC):
provider: ClassVar[str]
def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]: ...
```
**Summarizer:**
```python
class Summarizer(ABC):
provider: ClassVar[str]
def summarize(self, transcript: str) -> str: ...
```
**Реализации:**
- `NullTranscriber`, `NullSummarizer` — no-op (`provider: "null"`, дефолт)
- `FasterWhisperCPU`, `FasterWhisperGPU` — [docs/plugins/transcriber.md](../docs/plugins/transcriber.md)
- `QwenLocal` — map-reduce суммаризация через llama.cpp — [docs/plugins/summarizer.md](../docs/plugins/summarizer.md)
**Как добавить новый плагин:**
1. Создайте класс, наследующий Transcriber/Summarizer
2. Декоратор: `@register_transcriber` или `@register_summarizer`
3. Укажите провайдера в `config/plugins.yaml`
Все контракты и фабрика: [docs/plugins/contracts.md](../docs/plugins/contracts.md).
## Celery воркеры
Пост-обработка (транскрибирование, суммаризация, email-уведомления и
приглашения) выполняется в Celery-воркерах. См. [workers/README.md](../workers/README.md).
## Решение проблем
**ImportError:**
```bash
uv sync --all-extras
```
**Ошибка подключения к БД:**
```bash
docker compose -f ../deploy/docker-compose.yml ps
echo $DATABASE_URL
```
**Миграции не выполняются:**
```bash
uv run alembic current
uv run alembic downgrade base
uv run alembic upgrade head
```
**Ошибки типов:**
- Python 3.12+: `python --version`
- Пересборка: `uv sync --refresh`
## Процесс разработки
1. **Ветка:** `git checkout -b feature/my-feature`
2. **Напишите тесты:** `tests/test_*.py`
3. **Реализуйте** в соответствующем модуле
4. **Качество:** `pytest -q && ruff check . && mypy .`
5. **Коммит:** включите резюме тестов
Пример:
```
feat: добавить реестр Transcriber плагинов
- Реализовать декоратор @register_transcriber
- Добавить NullTranscriber no-op реализацию
- Добавить test_plugins_factory.py
Тесты: пройдены
```
## Ссылки
- [Корневой README](../README.md) — обзор проекта
- [Схема БД](../docs/db/schema.md) — ER диаграмма & дизайн
- [Контракты плагинов](../docs/plugins/contracts.md) — гайд расширений плагинов
- [API справка](../docs/api/README.md) — endpoint'ы
- [Архитектура](../docs/architecture/README.md) — дизайн системы
- [Dev Setup](../docs/deploy/dev-setup.md) — локальное окружение

149
backend/alembic.ini Normal file
View File

@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
backend/alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration with an async dbapi.

93
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,93 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from core.config import get_settings
from models import Base
# это объект конфига Alembic, который предоставляет
# доступ к значениям в используемом .ini файле.
config = context.config
# Интерпретировать конфиг файл для Python логирования.
# Эта строка устанавливает логгеры в основном.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Использовать Settings приложения (переменные окружения / .env) вместо alembic.ini
# для URL БД.
config.set_main_option("sqlalchemy.url", get_settings().database_url)
# добавить объект MetaData вашей модели здесь
# для поддержки 'autogenerate'
target_metadata = Base.metadata
# другие значения конфига, определённые потребностями env.py,
# можно получить:
# my_important_option = config.get_main_option("my_important_option")
# ... и т.д.
def run_migrations_offline() -> None:
"""Запустить миграции в 'offline' режиме.
Это настраивает контекст только с URL
и без Engine, хотя Engine также приемлем
здесь. Пропуская создание Engine
нам даже не нужен доступный DBAPI.
Вызовы context.execute() здесь выдают заданную строку в
вывод скрипта.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""В этом сценарии нам нужно создать Engine
и связать подключение с контекстом.
"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Запустить миграции в 'online' режиме."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

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

0
backend/api/__init__.py Normal file
View File

464
backend/api/admin.py Normal file
View File

@@ -0,0 +1,464 @@
"""Роутер администрирования: конференции, пользователи, команды, настройки.
Все эндпоинты требуют роль `admin` (`Depends(require_admin)`, 403 иначе).
Правки конференций/удаление переиспользуют `ConferenceService` (тот же
бизнес-слой, что и обычный роутер конференций) — админ проходит проверку
владения как «или владелец, или админ» (см. `ConferenceService._ensure_owner_or_admin`).
Рассылка приглашений и вся отправка писем — только в Celery-задачах; здесь
лишь постановка в очередь и немедленный ответ `202`.
Справочник команд (`teams`) — простой CRUD без бизнес-правил, кроме
уникальности названия; привязка пользователя к команде — `users.team_id`
(`ON DELETE SET NULL`).
"""
import uuid
from pathlib import Path
from typing import Annotated
import anyio
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import require_admin
# Алиас обязателен: ниже в этом модуле уже есть роутер-хендлер `get_settings`
# (`GET /admin/settings`) — без переименования он затирает имя импортированной
# функции в globals модуля (последнее связывание имени побеждает).
from core.config import get_settings as get_app_settings
from core.db import get_session
from core.plugins.config import InstanceConfig
from core.security import hash_password
from models.conference import Conference
from models.user import User
from repositories.admin import AdminConferenceRepository, AdminUserRepository, TeamRepository
from repositories.users import UserRepository
from schemas.admin import (
AdminConferenceListOut,
AdminConferenceOut,
AdminUserCreateIn,
AdminUserListOut,
AdminUserOut,
AdminUserUpdateIn,
InvitationsSendIn,
SettingsOut,
TeamCreateIn,
TeamListOut,
TeamOut,
TeamUpdateIn,
)
from schemas.conferences import ConferenceUpdateIn
from services.ai_levels import detect_ai_levels
from services.avatars import AvatarInvalidTypeError, AvatarTooLargeError, avatar_url
from services.conferences import (
ConferenceActiveError,
ConferenceNotFoundError,
ConferenceService,
InvalidConferenceStateError,
NotConferenceOwnerError,
)
from services.instance_settings import (
InstanceSettingsService,
InvalidAiLevelError,
InvalidEmailDomainError,
InvalidTimezoneError,
SettingsUpdateIn,
)
from services.invitations_producer import enqueue_invitations
from services.pipeline_producer import transcription_queue_served
from services.profile import resolve_team_name, set_avatar
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
DEFAULT_LIMIT = 50
MAX_LIMIT = 200
# --- Конференции ------------------------------------------------------------------
@router.get("/conferences", response_model=AdminConferenceListOut)
async def list_conferences(
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
status_filter: Annotated[str | None, Query(alias="status")] = None,
q: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query(gt=0, le=MAX_LIMIT)] = DEFAULT_LIMIT,
offset: Annotated[int, Query(ge=0)] = 0,
) -> AdminConferenceListOut:
"""Список всех конференций инстанса с фильтром по статусу и текстовым поиском."""
rows, total = await AdminConferenceRepository(session).list_paginated(
status=status_filter, q=q, limit=limit, offset=offset
)
service = ConferenceService(session)
items = [
_to_admin_conference_out(
service, conference, viewer_id=admin.id, owner_name=owner_name, owner_email=owner_email
)
for conference, owner_name, owner_email in rows
]
return AdminConferenceListOut(items=items, total=total)
@router.patch("/conferences/{conference_id}", response_model=AdminConferenceOut)
async def update_conference(
conference_id: uuid.UUID,
data: ConferenceUpdateIn,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> AdminConferenceOut:
"""Изменить любую конференцию инстанса (реюз `ConferenceService.update`)."""
service = ConferenceService(session)
try:
conference = await service.update(conference_id, actor=admin, data=data)
except ConferenceNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc
except NotConferenceOwnerError as exc: # недостижимо для admin, оставлено для полноты
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_owner") from exc
except InvalidConferenceStateError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
owner_name, owner_email = await _load_owner(session, conference)
return _to_admin_conference_out(
service, conference, viewer_id=admin.id, owner_name=owner_name, owner_email=owner_email
)
@router.delete("/conferences/{conference_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_conference(
conference_id: uuid.UUID,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> None:
"""Удалить любую конференцию инстанса (реюз `ConferenceService.delete`, 409 для активной)."""
service = ConferenceService(session)
try:
await service.delete(conference_id, actor=admin)
except ConferenceNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc
except ConferenceActiveError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="conference_active"
) from exc
@router.post(
"/conferences/{conference_id}/invitations",
status_code=status.HTTP_202_ACCEPTED,
)
async def send_conference_invitations(
conference_id: uuid.UUID,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
data: InvitationsSendIn = InvitationsSendIn(),
) -> None:
"""Поставить в очередь ручную рассылку .ics-приглашений (отправка — только в Celery)."""
conference = await session.get(Conference, conference_id)
if conference is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found")
enqueue_invitations(conference_id, emails=data.emails)
# --- Пользователи ------------------------------------------------------------------
@router.get("/users", response_model=AdminUserListOut)
async def list_users(
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
q: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query(gt=0, le=MAX_LIMIT)] = DEFAULT_LIMIT,
offset: Annotated[int, Query(ge=0)] = 0,
) -> AdminUserListOut:
"""Список всех пользователей инстанса с текстовым поиском по email/имени."""
rows, total = await AdminUserRepository(session).list_paginated(q=q, limit=limit, offset=offset)
media_root = _media_root()
items = [
_to_admin_user_out(user, team_name=team_name, media_root=media_root)
for user, team_name in rows
]
return AdminUserListOut(items=items, total=total)
@router.post("/users", response_model=AdminUserOut, status_code=status.HTTP_201_CREATED)
async def create_user(
data: AdminUserCreateIn,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> AdminUserOut:
"""Создать пользователя от имени администратора.
В отличие от самостоятельной регистрации (`POST /auth/register`),
email сразу считается подтверждённым (`email_verified=True`) — письмо с
подтверждением не отправляется; роль по умолчанию — `user`. Дубль email —
409 `email_already_registered` (тот же код, что у публичной регистрации);
несуществующая команда — 404 `team_not_found`.
"""
repo = UserRepository(session)
if await repo.get_by_email(data.email) is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="email_already_registered")
if data.team_id is not None:
team = await TeamRepository(session).get(data.team_id)
if team is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="team_not_found")
user = await repo.create(
email=data.email,
name_user=data.name_user,
password_hash=hash_password(data.password),
team_id=data.team_id,
)
user.email_verified = True
await session.commit()
team_name = await resolve_team_name(session, user.team_id)
return _to_admin_user_out(user, team_name=team_name, media_root=_media_root())
@router.get("/users/{user_id}", response_model=AdminUserOut)
async def get_user(
user_id: uuid.UUID,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> AdminUserOut:
"""Карточка профиля пользователя — те же данные, что в своём профиле."""
row = await AdminUserRepository(session).get_with_team(user_id)
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found")
user, team_name = row
return _to_admin_user_out(user, team_name=team_name, media_root=_media_root())
@router.patch("/users/{user_id}", response_model=AdminUserOut)
async def update_user(
user_id: uuid.UUID,
data: AdminUserUpdateIn,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> AdminUserOut:
"""Изменить роль/блокировку/ФИО/команду пользователя.
Запрет самоизменения (409) распространяется только на `role`/`is_blocked` —
своё ФИО/команду админ менять может (та же карточка).
"""
if user_id == admin.id and (data.role is not None or data.is_blocked is not None):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="cannot_modify_self")
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found")
if data.role is not None:
user.role = data.role
if data.is_blocked is not None:
user.is_blocked = data.is_blocked
if data.name_user is not None:
user.name_user = data.name_user
if "team_id" in data.model_fields_set:
# Явная передача (в т.ч. `null`) — назначить/снять команду; отсутствие
# поля в запросе значение не трогает (тот же паттерн, что
# `summary_recipients` в `services/conferences.py`).
if data.team_id is not None:
team = await TeamRepository(session).get(data.team_id)
if team is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="team_not_found")
user.team_id = data.team_id
await session.commit()
team_name = await resolve_team_name(session, user.team_id)
return _to_admin_user_out(user, team_name=team_name, media_root=_media_root())
@router.post("/users/{user_id}/avatar", response_model=AdminUserOut)
async def upload_user_avatar(
user_id: uuid.UUID,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
file: Annotated[UploadFile, File()],
) -> AdminUserOut:
"""Загрузить аватар любому пользователю (та же валидация, что `POST /users/me/avatar`)."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found")
try:
await set_avatar(_media_root(), user, file)
except AvatarTooLargeError as exc:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="avatar_too_large"
) from exc
except AvatarInvalidTypeError as exc:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="avatar_invalid_type"
) from exc
await session.commit()
team_name = await resolve_team_name(session, user.team_id)
return _to_admin_user_out(user, team_name=team_name, media_root=_media_root())
# --- Команды ------------------------------------------------------------------------
@router.get("/teams", response_model=TeamListOut)
async def list_teams(
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> TeamListOut:
"""Список всех команд, отсортированный по названию."""
items, total = await TeamRepository(session).list_all()
return TeamListOut(items=[TeamOut.model_validate(team) for team in items], total=total)
@router.post("/teams", response_model=TeamOut, status_code=status.HTTP_201_CREATED)
async def create_team(
data: TeamCreateIn,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> TeamOut:
"""Создать команду; дубль названия (регистрозависимо) — 409."""
repo = TeamRepository(session)
if await repo.get_by_name(data.name) is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="team_name_taken")
team = await repo.create(data.name)
await session.commit()
return TeamOut.model_validate(team)
@router.patch("/teams/{team_id}", response_model=TeamOut)
async def update_team(
team_id: uuid.UUID,
data: TeamUpdateIn,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> TeamOut:
"""Переименовать команду; нет команды — 404, дубль названия — 409."""
repo = TeamRepository(session)
team = await repo.get(team_id)
if team is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="team_not_found")
existing = await repo.get_by_name(data.name)
if existing is not None and existing.id != team_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="team_name_taken")
team.name = data.name
await session.commit()
return TeamOut.model_validate(team)
@router.delete("/teams/{team_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_team(
team_id: uuid.UUID,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> None:
"""Удалить команду (у пользователей `team_id` обнулится, ON DELETE SET NULL); нет — 404."""
repo = TeamRepository(session)
team = await repo.get(team_id)
if team is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="team_not_found")
await repo.delete(team)
await session.commit()
# --- Настройки инстанса -------------------------------------------------------------
@router.get("/settings", response_model=SettingsOut)
async def get_settings(
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> SettingsOut:
"""Текущие эффективные настройки инстанса.
`transcription_queue_served` вычисляется блокирующим вызовом Celery
(`app.control.inspect`, ждёт ответа брокера/воркеров) — выносится в поток
через `anyio.to_thread.run_sync`, чтобы не блокировать event loop.
"""
cfg = await InstanceSettingsService(session).get()
queue_served = await anyio.to_thread.run_sync(transcription_queue_served)
return _to_settings_out(cfg, transcription_queue_served=queue_served)
@router.put("/settings", response_model=SettingsOut)
async def update_settings(
data: SettingsUpdateIn,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> SettingsOut:
"""Частично обновить настройки инстанса; недоступный уровень AI/таймзона/домен — 400."""
service = InstanceSettingsService(session)
try:
cfg = await service.update(data)
except (InvalidAiLevelError, InvalidTimezoneError, InvalidEmailDomainError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
queue_served = await anyio.to_thread.run_sync(transcription_queue_served)
return _to_settings_out(cfg, transcription_queue_served=queue_served)
def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -> SettingsOut:
"""Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI."""
return SettingsOut(
chat_enabled=cfg.chat.enabled,
transcription_enabled=cfg.transcriber.enabled,
ai_level=cfg.ai_level,
ai_levels=detect_ai_levels(cfg),
transcription_queue_served=transcription_queue_served,
summary_recipients=cfg.summary_recipients,
display_timezone=cfg.display_timezone,
registration_team_choice=cfg.registration_team_choice,
registration_email_domain_enabled=cfg.registration_email_domain_enabled,
registration_email_domain=cfg.registration_email_domain,
)
def _to_admin_conference_out(
service: ConferenceService,
conference: Conference,
*,
viewer_id: uuid.UUID,
owner_name: str | None,
owner_email: str | None,
) -> AdminConferenceOut:
"""Дополнить `ConferenceOut` данными владельца для админ-таблицы конференций.
`participants` намеренно не заполняется — та же логика, что у `/my`
(список не раздувает состав, ADR-003, п.5); `organizer_name` переиспользует
уже загруженное здесь имя владельца (`owner_name`) — повторного запроса не нужно.
"""
base = service.to_out(conference, viewer_id=viewer_id, organizer_name=owner_name)
return AdminConferenceOut(**base.model_dump(), owner_name=owner_name, owner_email=owner_email)
async def _load_owner(
session: AsyncSession, conference: Conference
) -> tuple[str | None, str | None]:
"""Имя/email владельца конференции (`None`/`None`, если владельца нет — ADR-001)."""
if conference.owner_id is None:
return None, None
owner = await session.get(User, conference.owner_id)
if owner is None:
return None, None
return owner.name_user, owner.email
def _media_root() -> Path:
"""Каталог загруженных медиа-файлов (см. `core/config.py::Settings.media_root`)."""
return Path(get_app_settings().media_root)
def _to_admin_user_out(user: User, *, team_name: str | None, media_root: Path) -> AdminUserOut:
"""Собрать `AdminUserOut` — та же карточка, что и `UserProfileOut`, + модерация."""
return AdminUserOut(
id=user.id,
email=user.email,
name_user=user.name_user,
role=user.role,
is_blocked=user.is_blocked,
email_verified=user.email_verified,
created_at=user.created_at,
team_id=user.team_id,
avatar_url=avatar_url(media_root, user.avatar_path),
team_name=team_name,
)

188
backend/api/auth.py Normal file
View File

@@ -0,0 +1,188 @@
"""Роутер аутентификации: регистрация, подтверждение email, JWT access/refresh, logout."""
from typing import Annotated
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from core.db import get_session
from core.redis import redis_client
from models.user import User
from repositories.admin import TeamRepository
from schemas.auth import (
RegisterIn,
RegistrationOptionsOut,
RegistrationTeamOptionOut,
TokenOut,
UserOut,
VerifyEmailIn,
)
from services.auth import (
AuthService,
EmailAlreadyRegisteredError,
EmailNotVerifiedError,
InvalidCredentialsError,
InvalidEmailDomainError,
InvalidRefreshTokenError,
InvalidTeamSelectionError,
InvalidVerificationTokenError,
)
from services.email import create_email_backend
from services.instance_settings import InstanceSettingsService
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
REFRESH_COOKIE_NAME = "refresh_token"
REFRESH_COOKIE_PATH = "/api/v1/auth"
def get_auth_service(session: Annotated[AsyncSession, Depends(get_session)]) -> AuthService:
"""Собрать `AuthService` с реальными зависимостями (БД, Redis, email-бэкенд из настроек)."""
return AuthService(
session=session, redis=redis_client, email_backend=create_email_backend(get_settings())
)
@router.get("/registration-options", response_model=RegistrationOptionsOut)
async def registration_options(
session: Annotated[AsyncSession, Depends(get_session)],
) -> RegistrationOptionsOut:
"""Публичные опции карточки регистрации: выбор команды и верификация домена email.
Список команд отдаётся только при включённой настройке инстанса
`registration_team_choice` — иначе пустой массив (справочник команд не
раскрывается, пока выбор выключен). `email_domain` — эталонный домен при
включённой настройке `registration_email_domain`, иначе `None`.
"""
cfg = await InstanceSettingsService(session).get()
teams: list[RegistrationTeamOptionOut] = []
if cfg.registration_team_choice:
items, _ = await TeamRepository(session).list_all()
teams = [RegistrationTeamOptionOut(id=team.id, name=team.name) for team in items]
email_domain = cfg.registration_email_domain if cfg.registration_email_domain_enabled else None
return RegistrationOptionsOut(
team_choice_enabled=cfg.registration_team_choice, teams=teams, email_domain=email_domain
)
@router.post("/register", status_code=status.HTTP_201_CREATED, response_model=UserOut)
async def register(
data: RegisterIn, service: Annotated[AuthService, Depends(get_auth_service)]
) -> User:
"""Зарегистрировать нового пользователя и отправить письмо для подтверждения email."""
try:
return await service.register(
email=data.email,
name_user=data.name_user,
password=data.password,
team_id=data.team_id,
)
except EmailAlreadyRegisteredError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="email_already_registered"
) from exc
except InvalidTeamSelectionError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_team_selection"
) from exc
except InvalidEmailDomainError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_email_domain"
) from exc
@router.post("/verify-email", status_code=status.HTTP_204_NO_CONTENT)
async def verify_email(
data: VerifyEmailIn, service: Annotated[AuthService, Depends(get_auth_service)]
) -> None:
"""Подтвердить email по токену, полученному в письме."""
try:
await service.verify_email(data.token)
except InvalidVerificationTokenError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_or_expired_token"
) from exc
@router.post("/token", response_model=TokenOut)
async def login(
response: Response,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
service: Annotated[AuthService, Depends(get_auth_service)],
) -> TokenOut:
"""OAuth2 password flow: вход по email (передаётся как `username`) и паролю."""
try:
pair = await service.login(email=form_data.username, password=form_data.password)
except InvalidCredentialsError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_credentials"
) from exc
except EmailNotVerifiedError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="email_not_verified"
) from exc
_set_refresh_cookie(response, pair.refresh_token)
return TokenOut(access_token=pair.access_token)
@router.post("/refresh", response_model=TokenOut)
async def refresh(
response: Response,
service: Annotated[AuthService, Depends(get_auth_service)],
refresh_token: Annotated[str | None, Cookie(alias=REFRESH_COOKIE_NAME)] = None,
) -> TokenOut:
"""Ротировать refresh-токен из cookie и выдать новый access-токен."""
if refresh_token is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="missing_refresh_token"
)
try:
pair = await service.refresh(refresh_token)
except InvalidRefreshTokenError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_refresh_token"
) from exc
_set_refresh_cookie(response, pair.refresh_token)
return TokenOut(access_token=pair.access_token)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
response: Response,
service: Annotated[AuthService, Depends(get_auth_service)],
refresh_token: Annotated[str | None, Cookie(alias=REFRESH_COOKIE_NAME)] = None,
) -> None:
"""Отозвать refresh-токен (удалить из Redis) и погасить cookie."""
if refresh_token is not None:
await service.logout(refresh_token)
settings = get_settings()
response.delete_cookie(
REFRESH_COOKIE_NAME,
path=REFRESH_COOKIE_PATH,
secure=settings.auth_cookie_secure,
httponly=True,
samesite="strict",
)
def _set_refresh_cookie(response: Response, refresh_token: str) -> None:
"""Установить httpOnly SameSite=Strict cookie с refresh-токеном.
Флаг `Secure` управляется настройкой `auth_cookie_secure` — в dev по
`http://localhost` его нужно отключать (см. `core/config.py`), т.к.
Safari (в отличие от Chrome) не сохраняет Secure-cookie без HTTPS.
"""
settings = get_settings()
response.set_cookie(
key=REFRESH_COOKIE_NAME,
value=refresh_token,
httponly=True,
secure=settings.auth_cookie_secure,
samesite="strict",
path=REFRESH_COOKIE_PATH,
max_age=settings.refresh_token_ttl_days * 24 * 3600,
)

152
backend/api/chat.py Normal file
View File

@@ -0,0 +1,152 @@
"""WS-роутер текстового чата конференции: `WS /api/v1/conferences/{id}/chat`.
Протокол: `connect` -> `accept()` -> клиент шлёт `{"type":"auth","token":...}`
первым сообщением (таймаут 10 с; токен не query-параметр — не палим его в
логах nginx) -> сервер проверяет тоггл `chat.enabled` и LiveKit-токен ->
история последних 50 сообщений открытой сессии -> двунаправленный обмен
`{"type":"message","text":...}` через Redis pub/sub (echo отправителю тоже).
"""
import asyncio
import logging
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from pydantic import ValidationError
from redis.asyncio.client import PubSub
from sqlalchemy.ext.asyncio import AsyncSession
from core.db import get_session
from core.redis import redis_client
from models.conference import Conference
from schemas.chat import (
ChatAuthIn,
ChatErrorOut,
ChatHistoryOut,
ChatMessageEventOut,
ChatMessageIn,
ChatMessageOut,
)
from services.chat import ChatAuthError, ChatIdentity, ChatService, InvalidTokenError, chat_channel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/conferences", tags=["chat"])
# Таймаут ожидания первого (auth) сообщения клиента.
AUTH_TIMEOUT_SECONDS = 10.0
@router.websocket("/{conference_id}/chat")
async def chat_websocket(
websocket: WebSocket,
conference_id: uuid.UUID,
session: Annotated[AsyncSession, Depends(get_session)],
) -> None:
"""WS-эндпоинт текстового чата конференции — единая аутентификация LiveKit-токеном."""
await websocket.accept()
service = ChatService(session)
try:
identity = await _authenticate(websocket, service)
conference = await service.ensure_chat_open(conference_id, identity=identity)
except ChatAuthError as exc:
await _close_quietly(websocket, exc.close_code)
return
pubsub = redis_client.pubsub()
channel = chat_channel(conference.id)
# Подписка ДО чтения истории: сообщение,
# опубликованное другим клиентом в окне между SELECT истории и
# subscribe, иначе теряется для подключающегося клиента — Redis начинает
# буферизовать входящие publish для этого соединения сразу после
# subscribe, до первого вызова `get_message`. На стыке возможен дубликат
# (то же сообщение и в history, и в первом pub/sub-сообщении) — безопаснее
# дедуплицировать по `id`, чем потерять сообщение.
await pubsub.subscribe(channel)
try:
history = await service.history(conference)
await websocket.send_json(ChatHistoryOut(messages=history).model_dump(mode="json"))
seen_ids = {item.id for item in history}
async with asyncio.TaskGroup() as tg:
tg.create_task(_pump_pubsub_to_websocket(websocket, pubsub, seen_ids))
tg.create_task(_pump_websocket_to_service(websocket, service, conference, identity))
except* WebSocketDisconnect:
# Штатное закрытие соединения клиентом — не ошибка.
pass
except* ChatAuthError as eg:
# Допуск был проверен только при коннекте — за время жизни
# долгоживущего WS (LiveKit-токен TTL 6 часов) конференция могла
# завершиться; `persist_and_publish` бросает `ChatUnavailableError`
# при попытке создать сессию пайплайна для уже мёртвой
# конференции — закрываем с тем же кодом, что и при отказе
# на коннекте.
# `except*` всегда связывает `ExceptionGroup` (PEP 654) — на рантайме
# `eg.exceptions[0]` гарантированно `ChatAuthError`; mypy после
# нескольких подряд идущих `except*` моделирует тип `eg` неточно
# (union с "голым" `ChatAuthError`, не имеющим `.exceptions`).
await _close_quietly(websocket, eg.exceptions[0].close_code) # type: ignore[union-attr]
finally:
# Всегда отписываемся и закрываем pubsub-соединение, иначе при частых
# обрывах соединений копятся забытые подписки на стороне Redis.
await pubsub.unsubscribe(channel)
# `PubSub.aclose` в redis-py не аннотирован (untyped def) несмотря на
# `py.typed` пакета — узкий игнор именно этого вызова.
await pubsub.aclose() # type: ignore[no-untyped-call]
async def _authenticate(websocket: WebSocket, service: ChatService) -> ChatIdentity:
"""Дождаться первого (auth) сообщения клиента с таймаутом и проверить LiveKit-токен."""
try:
raw = await asyncio.wait_for(websocket.receive_text(), timeout=AUTH_TIMEOUT_SECONDS)
except (TimeoutError, WebSocketDisconnect) as exc:
raise InvalidTokenError from exc
try:
envelope = ChatAuthIn.model_validate_json(raw)
except ValidationError as exc:
raise InvalidTokenError from exc
return await service.authenticate(envelope.token)
async def _pump_pubsub_to_websocket(
websocket: WebSocket, pubsub: PubSub, seen_ids: set[int]
) -> None:
"""Читать сообщения Redis pub/sub канала чата и пересылать их подключённому клиенту.
`seen_ids` — id сообщений, уже отправленных клиенту в `history` (на
стыке подписки и SELECT истории возможен дубликат, см. докстринг
`chat_websocket`) — такие сообщения не пересылаются повторно.
"""
while True:
raw = await pubsub.get_message(ignore_subscribe_messages=True, timeout=None)
if raw is None:
continue
message = ChatMessageOut.model_validate_json(raw["data"])
if message.id in seen_ids:
continue
seen_ids.add(message.id)
await websocket.send_json(ChatMessageEventOut(message=message).model_dump(mode="json"))
async def _pump_websocket_to_service(
websocket: WebSocket, service: ChatService, conference: Conference, identity: ChatIdentity
) -> None:
"""Читать текстовые сообщения клиента, валидировать и сохранять+публиковать их."""
while True:
raw = await websocket.receive_text()
try:
envelope = ChatMessageIn.model_validate_json(raw)
except ValidationError:
await websocket.send_json(ChatErrorOut(code="invalid_message").model_dump(mode="json"))
continue
await service.persist_and_publish(conference, identity=identity, text=envelope.text)
async def _close_quietly(websocket: WebSocket, code: int) -> None:
"""Закрыть WS с заданным кодом, не роняя обработчик, если клиент уже отвалился."""
try:
await websocket.close(code=code)
except Exception: # noqa: BLE001 — соединение уже могло быть разорвано клиентом
logger.debug("chat websocket: close(%s) на уже разорванном соединении", code)

255
backend/api/conferences.py Normal file
View File

@@ -0,0 +1,255 @@
"""Роутер конференций: создание, «Мои конференции», календарь, резолв, вход, правки (ADR-001)."""
import uuid
from datetime import UTC, datetime, timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import get_current_user
from core.db import get_session
from core.rate_limit import enforce_rate_limit
from models.user import User
from schemas.conferences import (
ConferenceCreateIn,
ConferenceOut,
ConferenceUpdateIn,
GuestJoinIn,
JoinIn,
JoinOut,
OccurrenceOut,
ResolveOut,
)
from services.conference_access import (
ConferenceEndedError,
InvalidPasswordError,
PasswordRequiredError,
)
from services.conferences import (
ConferenceActiveError,
ConferenceNotFoundError,
ConferenceService,
InvalidConferenceStateError,
InviteeUserNotFoundError,
NotConferenceOwnerError,
)
router = APIRouter(prefix="/api/v1/conferences", tags=["conferences"])
# Максимальная ширина диапазона `from`/`to` для GET /calendar — защита от
# случайного запроса на годы вперёд (календарь UI показывает недели/месяцы).
MAX_CALENDAR_RANGE = timedelta(days=62)
@router.post("", status_code=status.HTTP_201_CREATED, response_model=ConferenceOut)
async def create_conference(
data: ConferenceCreateIn,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> ConferenceOut:
"""Создать конференцию: без `scheduled_at` — мгновенная (создатель входит сразу же)."""
service = ConferenceService(session)
try:
conference, join = await service.create(owner=user, data=data)
except InviteeUserNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="invitee_user_not_found"
) from exc
return await service.to_detail_out(conference, viewer=user, join=join)
@router.get("/my", response_model=list[ConferenceOut])
async def list_my_conferences(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> list[ConferenceOut]:
"""Закреплённые конференции + предстоящие разовые владельца."""
service = ConferenceService(session)
return await service.list_my(owner=user)
@router.get("/calendar", response_model=list[OccurrenceOut])
async def get_calendar(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
from_: Annotated[datetime, Query(alias="from")],
to: Annotated[datetime, Query()],
) -> list[OccurrenceOut]:
"""Развёртка вхождений закреплённых (с повторением) и разовых плановых конференций владельца."""
t_from = _require_utc(from_)
t_to = _require_utc(to)
if t_to <= t_from or t_to - t_from > MAX_CALENDAR_RANGE:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="invalid_range"
)
service = ConferenceService(session)
return await service.list_calendar(owner=user, t_from=t_from, t_to=t_to)
@router.get("/resolve", response_model=ResolveOut)
async def resolve_conference(
request: Request,
session: Annotated[AsyncSession, Depends(get_session)],
q: Annotated[str, Query(min_length=1)],
) -> ResolveOut:
"""Найти конференцию по номеру или ссылке — без auth; rate limit; единообразный 404.
Для завершённой конференции (`status=ended`) отдаём минимальный ответ —
только `id`/`title`/`status`, без `is_closed`/`requires_password` (ADR-001,
п.4, уточнение резолва): вход в неё невозможен в любом случае (410 у
join/guest-join), а признак закрытости неактуален для мёртвой конференции.
"""
await enforce_rate_limit(f"resolve:{_client_ip(request)}")
service = ConferenceService(session)
conference = await service.resolve(q)
if conference is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not_found")
if conference.status == "ended":
return ResolveOut(id=conference.id, title=conference.title, status=conference.status)
return ResolveOut(
id=conference.id,
title=conference.title,
status=conference.status,
is_closed=conference.is_closed,
requires_password=conference.is_closed,
)
@router.post("/{conference_id}/join", response_model=JoinOut)
async def join_conference(
conference_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
data: JoinIn = JoinIn(),
) -> JoinOut:
"""Войти в конференцию зарегистрированным пользователем."""
service = ConferenceService(session)
try:
return await service.join_as_user(conference_id, user=user, password=data.password)
except ConferenceNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc
except ConferenceEndedError as exc:
raise HTTPException(status_code=status.HTTP_410_GONE, detail="conference_ended") from exc
except PasswordRequiredError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="password_required"
) from exc
except InvalidPasswordError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password"
) from exc
@router.post("/{conference_id}/guest-join", response_model=JoinOut)
async def guest_join_conference(
conference_id: uuid.UUID,
request: Request,
data: GuestJoinIn,
session: Annotated[AsyncSession, Depends(get_session)],
) -> JoinOut:
"""Войти гостем: представиться (имя обязательно, email факультативен) — без auth, rate limit."""
await enforce_rate_limit(f"guest_join:{_client_ip(request)}")
service = ConferenceService(session)
try:
return await service.join_as_guest(conference_id, data=data)
except ConferenceNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc
except ConferenceEndedError as exc:
raise HTTPException(status_code=status.HTTP_410_GONE, detail="conference_ended") from exc
except PasswordRequiredError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="password_required"
) from exc
except InvalidPasswordError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password"
) from exc
@router.get("/{conference_id}", response_model=ConferenceOut)
async def get_conference(
conference_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> ConferenceOut:
"""Детальная карточка конференции (с полным составом участников); владелец или администратор."""
service = ConferenceService(session)
try:
conference = await service.get_detail(conference_id, actor=user)
except ConferenceNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc
except NotConferenceOwnerError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_owner") from exc
return await service.to_detail_out(conference, viewer=user)
@router.patch("/{conference_id}", response_model=ConferenceOut)
async def update_conference(
conference_id: uuid.UUID,
data: ConferenceUpdateIn,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> ConferenceOut:
"""Изменить конференцию: разрешено владельцу или администратору."""
service = ConferenceService(session)
try:
conference = await service.update(conference_id, actor=user, data=data)
except ConferenceNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc
except NotConferenceOwnerError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_owner") from exc
except InvalidConferenceStateError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except InviteeUserNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="invitee_user_not_found"
) from exc
return await service.to_detail_out(conference, viewer=user)
@router.delete("/{conference_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_conference(
conference_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> None:
"""Удалить конференцию: запрещено для активной (409), разрешено владельцу/администратору."""
service = ConferenceService(session)
try:
await service.delete(conference_id, actor=user)
except ConferenceNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc
except NotConferenceOwnerError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_owner") from exc
except ConferenceActiveError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="conference_active"
) from exc
def _require_utc(value: datetime) -> datetime:
"""Требовать явную таймзону и привести значение к UTC (в БД и API — только UTC)."""
if value.tzinfo is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="datetime_must_be_timezone_aware",
)
return value.astimezone(UTC)
def _client_ip(request: Request) -> str:
"""IP-адрес клиента для rate limit (без auth — ключ по IP, а не по пользователю)."""
return request.client.host if request.client else "unknown"

72
backend/api/deps.py Normal file
View File

@@ -0,0 +1,72 @@
"""Зависимости FastAPI для аутентификации (RBAC): user / guest / admin."""
import uuid
from typing import Annotated
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from core.db import get_session
from core.security import decode_token
from models.user import User
from repositories.users import UserRepository
# `auto_error=False`, чтобы отсутствие заголовка не приводило к автоматической
# ошибке — guest (отсутствие JWT) обрабатывается явно в get_current_user_optional.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token", auto_error=False)
async def get_current_user(
token: Annotated[str | None, Depends(oauth2_scheme)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> User:
"""Вернуть текущего пользователя по access-токену; 401 если не аутентифицирован."""
user = await _user_from_token(token, session)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="not_authenticated")
return user
async def get_current_user_optional(
token: Annotated[str | None, Depends(oauth2_scheme)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> User | None:
"""Вернуть текущего пользователя либо `None` для guest (без ошибки).
Роль guest в системе — это отсутствие JWT, а не отдельное enum-значение в БД.
"""
return await _user_from_token(token, session)
async def require_admin(user: Annotated[User, Depends(get_current_user)]) -> User:
"""Требовать роль `admin`; иначе 403."""
if user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin_required")
return user
async def _user_from_token(token: str | None, session: AsyncSession) -> User | None:
"""Общая логика резолва пользователя из access-токена (или None при любой проблеме).
Заблокированный администратором пользователь (`is_blocked`) трактуется
так же, как отсутствие пользователя — блокировка действует немедленно,
не дожидаясь истечения уже выданного access-токена.
"""
if token is None:
return None
try:
payload = decode_token(token)
except jwt.PyJWTError:
return None
if payload.get("type") != "access":
return None
try:
user_id = uuid.UUID(str(payload.get("sub")))
except (ValueError, TypeError):
return None
user = await UserRepository(session).get_by_id(user_id)
if user is not None and user.is_blocked:
return None
return user

40
backend/api/health.py Normal file
View File

@@ -0,0 +1,40 @@
"""Endpoint для проверки здоровья."""
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from core.db import get_session
from core.redis import redis_client
router = APIRouter()
@router.get("/api/health")
async def health(session: AsyncSession = Depends(get_session)) -> dict[str, bool | str]:
"""Отчет о статусе приложения и связи с БД/Redis.
Поле `version` — версия инстанса (`VIDCONF_VERSION` из `.env`,
пишет `install.sh` из корневого файла `VERSION`); футер админки
берёт его отсюда, а не из версии сборки фронтенда.
"""
db_ok = False
try:
await session.execute(text("SELECT 1"))
db_ok = True
except Exception: # noqa: BLE001
db_ok = False
redis_ok = False
try:
redis_ok = bool(await redis_client.ping())
except Exception: # noqa: BLE001
redis_ok = False
return {
"status": "ok",
"db": db_ok,
"redis": redis_ok,
"version": get_settings().vidconf_version,
}

View File

@@ -0,0 +1,63 @@
"""Приёмник webhook-событий LiveKit (без JWT — верификация подписью LiveKit)."""
import logging
from typing import Annotated, Any, cast
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from livekit import api
from sqlalchemy import CursorResult
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from core.db import get_session
from models.webhook_event import LivekitWebhookEvent
from services.webhook_handlers import WebhookDispatcher
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/livekit", tags=["livekit"])
@router.post("/webhook")
async def receive_webhook(
request: Request,
session: Annotated[AsyncSession, Depends(get_session)],
authorization: Annotated[str | None, Header()] = None,
) -> dict[str, str]:
"""Принять, верифицировать и обработать webhook-событие LiveKit.
Дедупликация по `event.id`: `INSERT ... ON CONFLICT DO NOTHING` в
`livekit_webhook_events` в одной транзакции с эффектами обработчика —
при конфликте (дубль) эффекты пропускаются, но ответ всё равно 200.
"""
settings = get_settings()
raw_body = await request.body()
receiver = api.WebhookReceiver(
api.TokenVerifier(settings.livekit_api_key, settings.livekit_api_secret)
)
try:
event = receiver.receive(raw_body.decode(), authorization or "")
except Exception as exc: # noqa: BLE001 — SDK кидает generic Exception на невалидную подпись
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_signature"
) from exc
insert_result = cast(
CursorResult[Any],
await session.execute(
pg_insert(LivekitWebhookEvent)
.values(event_id=event.id, event_type=event.event)
.on_conflict_do_nothing(index_elements=["event_id"])
),
)
if insert_result.rowcount == 0:
# Дубль уже обработанного события — пропускаем эффекты, но отвечаем 200.
await session.commit()
return {"status": "duplicate"}
dispatcher = WebhookDispatcher(session)
await dispatcher.dispatch(event)
await session.commit()
return {"status": "ok"}

125
backend/api/metrics.py Normal file
View File

@@ -0,0 +1,125 @@
"""Метрики Prometheus: латентность HTTP + gauge'и пайплайна и очередей.
`GET /metrics` — без авторизации (снаружи закрывается на уровне nginx, вне
периметра backend, см. `docs/deploy/scaling.md`/monitoring-часть devops):
Prometheus-серверы традиционно ходят напрямую в контейнер по внутренней
сети, а не через публичный `/api/`-гейтвей.
Gauge'и `vidconf_pipeline_sessions`/`vidconf_celery_queue_depth` намеренно
НЕ обновляются фоновой задачей — значения пересчитываются прямо в обработчике
запроса при каждом scrape (см. докстринг `metrics_endpoint`), поэтому их
асинхронные источники (БД, Redis) можно опросить обычным `await` вместо
реализации синхронного `prometheus_client.registry.Collector`.
"""
import time
from collections.abc import Awaitable, Callable
from fastapi import APIRouter, Depends, Request, Response
from prometheus_client import CONTENT_TYPE_LATEST, Gauge, Histogram, generate_latest
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.routing import Match
from core.db import get_session
from core.redis import redis_client
from models.session import PIPELINE_STATUSES
from repositories.conferences import ConferenceSessionRepository
router = APIRouter()
# --- Латентность HTTP-запросов по маршрутам --------------------------------
HTTP_REQUEST_DURATION_SECONDS = Histogram(
"vidconf_http_request_duration_seconds",
"Латентность HTTP-запросов backend по маршрутам",
labelnames=("method", "path", "status"),
)
async def prometheus_latency_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
"""Замерить латентность запроса и записать в `HTTP_REQUEST_DURATION_SECONDS`.
Метка `path` — шаблон маршрута (`/api/v1/conferences/{conference_id}`), а
не сырой URL: иначе каждый UUID/slug в пути породил бы собственную серию
меток (неограниченная кардинальность). Шаблон резолвится постфактум
поиском совпавшего маршрута среди `request.app.routes` (FastAPI/Starlette
не кладёт его в `request.scope` до входа в сам эндпоинт, а `call_next`
оборачивает вызов целиком) — тот же приём, что использует
`starlette.routing.Router` внутри себя для диспетчеризации.
"""
start = time.perf_counter()
response = await call_next(request)
duration = time.perf_counter() - start
path_template = _match_route_path(request)
HTTP_REQUEST_DURATION_SECONDS.labels(
method=request.method, path=path_template, status=str(response.status_code)
).observe(duration)
return response
def _match_route_path(request: Request) -> str:
"""Найти шаблон пути совпавшего маршрута; сырой `request.url.path`, если не найден (404)."""
for route in request.app.routes:
match, _ = route.matches(request.scope)
if match == Match.FULL:
return getattr(route, "path", request.url.path)
return request.url.path
# --- Gauge числа сеансов по статусу пайплайна -------------------------------
PIPELINE_SESSIONS = Gauge(
"vidconf_pipeline_sessions",
"Число сеансов конференций в каждом статусе пайплайна пост-обработки",
labelnames=("status",),
)
async def _refresh_pipeline_sessions_gauge(session: AsyncSession) -> None:
"""Пересчитать `vidconf_pipeline_sessions` по всем статусам `pipeline_status`."""
counts = await ConferenceSessionRepository(session).count_by_pipeline_status()
for status in PIPELINE_STATUSES:
PIPELINE_SESSIONS.labels(status=status).set(counts.get(status, 0))
# --- Gauge глубины очередей Celery (Redis) ----------------------------------
CELERY_QUEUES = ("transcription", "summarize", "notify", "celery")
"""Очереди, за которыми следим (`workers/celery_app.py::app.conf.task_routes`,
`docs/deploy/scaling.md`): выделенные `transcription`/`summarize`/`notify` +
дефолтная `celery` (обслуживающие задачи без явного маршрута)."""
CELERY_QUEUE_DEPTH = Gauge(
"vidconf_celery_queue_depth",
"Число задач, ожидающих обработки в очереди Celery (redis LLEN)",
labelnames=("queue",),
)
async def _refresh_celery_queue_depth_gauge() -> None:
"""Пересчитать `vidconf_celery_queue_depth` по всем отслеживаемым очередям.
Список Redis, лежащий за очередью Celery, называется так же, как сама
очередь (транспорт `kombu` с брокером `redis` кладёт задачи в список по
имени очереди) — `LLEN` даёт точную глубину backlog'а на момент scrape.
"""
for queue in CELERY_QUEUES:
depth = await redis_client.llen(queue)
CELERY_QUEUE_DEPTH.labels(queue=queue).set(depth)
@router.get("/metrics")
async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Response:
"""Отдать метрики Prometheus в формате text exposition.
Gauge'и пересчитываются прямо здесь (а не по расписанию/периодическим
коллектором) — значение в ответе всегда актуально на момент scrape,
ценой одного SELECT (группировка по `pipeline_status`) и `LLEN` на
каждую из 4 отслеживаемых очередей per запрос — Prometheus скрейпит
редко (обычно раз в 1530с), нагрузка пренебрежимо мала.
"""
await _refresh_pipeline_sessions_gauge(session)
await _refresh_celery_queue_depth_gauge()
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)

31
backend/api/teams.py Normal file
View File

@@ -0,0 +1,31 @@
"""Роутер справочника команд для аутентифицированных пользователей.
Отдельно от `/admin/teams` (админ-only CRUD): здесь только чтение полного
списка — нужно странице профиля (выбор команды). В отличие от
`GET /auth/registration-options`, список НЕ гасится тумблером
`registration_team_choice` (та настройка — только про публичную форму
регистрации, не про профиль уже аутентифицированного пользователя).
"""
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import get_current_user
from core.db import get_session
from models.user import User
from repositories.admin import TeamRepository
from schemas.admin import TeamOut
router = APIRouter(prefix="/api/v1/teams", tags=["teams"])
@router.get("", response_model=list[TeamOut])
async def list_teams(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> list[TeamOut]:
"""Полный справочник команд, отсортированный по названию (выбор команды в профиле)."""
items, _ = await TeamRepository(session).list_all()
return [TeamOut.model_validate(team) for team in items]

145
backend/api/users.py Normal file
View File

@@ -0,0 +1,145 @@
"""Роутер профиля текущего пользователя, аватара и списка пользователей."""
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import get_current_user
from core.config import get_settings
from core.db import get_session
from core.security import hash_password, verify_password
from models.user import User
from repositories.users import UserRepository
from schemas.auth import PasswordChangeIn, ProfileUpdateIn, UserListItemOut, UserProfileOut
from services.avatars import AvatarInvalidTypeError, AvatarTooLargeError, avatar_url
from services.profile import (
TeamNotFoundError,
clear_avatar,
resolve_team_name,
update_profile_fields,
)
from services.profile import set_avatar as _set_avatar
router = APIRouter(prefix="/api/v1/users", tags=["users"])
# Число совпадений, возвращаемых поиском по `q` (пикер участников).
SEARCH_LIMIT = 20
@router.get("/me", response_model=UserProfileOut)
async def read_current_user(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> UserProfileOut:
"""Вернуть профиль текущего аутентифицированного пользователя."""
return await _to_profile_out(session, user)
@router.patch("/me", response_model=UserProfileOut)
async def update_current_user(
data: ProfileUpdateIn,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> UserProfileOut:
"""Изменить ФИО и/или команду текущего пользователя; email — read-only."""
try:
await update_profile_fields(
session,
user,
name_user=data.name_user,
team_id=data.team_id,
team_id_is_set="team_id" in data.model_fields_set,
)
except TeamNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="team_not_found") from exc
await session.commit()
return await _to_profile_out(session, user)
@router.post("/me/avatar", response_model=UserProfileOut)
async def upload_current_user_avatar(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
file: Annotated[UploadFile, File()],
) -> UserProfileOut:
"""Загрузить аватар текущего пользователя (jpeg/png/webp, до 2 МБ)."""
try:
await _set_avatar(_media_root(), user, file)
except AvatarTooLargeError as exc:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="avatar_too_large"
) from exc
except AvatarInvalidTypeError as exc:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="avatar_invalid_type"
) from exc
await session.commit()
return await _to_profile_out(session, user)
@router.delete("/me/avatar", status_code=status.HTTP_204_NO_CONTENT)
async def delete_current_user_avatar(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> None:
"""Удалить аватар текущего пользователя."""
clear_avatar(_media_root(), user)
await session.commit()
@router.post("/me/password", status_code=status.HTTP_204_NO_CONTENT)
async def change_current_user_password(
data: PasswordChangeIn,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> None:
"""Сменить пароль текущего пользователя.
Refresh-сессии сознательно НЕ отзываются — отзыв всех сессий появится
вместе со сбросом пароля по email (v0.1.0, см. ADR-005
`docs/architecture/adr/005-password-reset-deferred.md`).
"""
if not verify_password(data.current_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_current_password"
)
user.password_hash = hash_password(data.new_password)
await session.commit()
@router.get("", response_model=list[UserListItemOut])
async def list_users(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
q: Annotated[str | None, Query()] = None,
) -> list[UserListItemOut]:
"""Пикер участников конференции: без `q` — полный список; с `q` — поиск имя/email."""
media_root = _media_root()
users = await UserRepository(session).search(q=q, limit=SEARCH_LIMIT)
return [
UserListItemOut(
id=u.id, display_name=u.name_user, avatar_url=avatar_url(media_root, u.avatar_path)
)
for u in users
]
def _media_root() -> Path:
"""Каталог загруженных медиа-файлов (см. `core/config.py::Settings.media_root`)."""
return Path(get_settings().media_root)
async def _to_profile_out(session: AsyncSession, user: User) -> UserProfileOut:
"""Собрать `UserProfileOut` — общая сборка для своего профиля и карточки в админке."""
team_name = await resolve_team_name(session, user.team_id)
return UserProfileOut(
id=user.id,
email=user.email,
name_user=user.name_user,
role=user.role,
avatar_url=avatar_url(_media_root(), user.avatar_path),
team_id=user.team_id,
team_name=team_name,
)

0
backend/core/__init__.py Normal file
View File

138
backend/core/config.py Normal file
View File

@@ -0,0 +1,138 @@
"""Конфигурация приложения, загруженная из переменных окружения / файла .env."""
from functools import lru_cache
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from core.plugins.config import AiLevel
class Settings(BaseSettings):
"""Центральные параметры приложения.
Значения читаются из переменных окружения (или файла `.env`).
"""
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str = "postgresql+asyncpg://vidconf:vidconf@localhost:5432/vidconf"
redis_url: str = "redis://localhost:6379/0"
plugins_config_path: str = "../config/plugins.yaml"
# --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из файла `VERSION` (корень репозитория) в
# `.env` при каждой установке/обновлении — здесь только чтение готового
# значения. Отдаётся в `GET /api/health` (футер админки, Блок 4).
vidconf_version: str = "0.0.0"
# Домен не должен попадать в список special-use/reserved (RFC 6761,
# напр. `.local`/`.test`): email-validator (`EmailStr`) их отклоняет, а
# раньше это ловилось и на выходе — старый дефолт `admin@vidconf.local`
# ронял `GET /users/me` 500 `ResponseValidationError`, пока `UserOut.email`
# был `EmailStr`. `.example` (RFC 2606) email-validator пропускает.
seed_admin_email: str = "admin@vidconf.example"
seed_admin_password: str = "change-me"
# --- Auth (JWT + email-подтверждение) ---
jwt_secret: str = "dev-only-insecure-secret-change-me"
access_token_ttl_minutes: int = 15
refresh_token_ttl_days: int = 14
email_verification_ttl_hours: int = 24
frontend_url: str = "http://localhost:5173"
# Флаг Secure для refresh-cookie. false нужен только для dev по
# http://localhost (Safari, в отличие от Chrome, не сохраняет
# Secure-cookie без HTTPS); в проде обязательно true.
auth_cookie_secure: bool = True
# --- LiveKit ---
livekit_api_key: str = "devkey"
livekit_api_secret: str = "change-me-livekit-secret"
livekit_public_url: str = "ws://localhost:7880"
# Внутренний server-to-server URL для вызовов LiveKit RoomService (Celery
# maintenance-задача); в отличие от `livekit_public_url` не проксируется
# через nginx/TLS для браузера. LiveKit SDK сам нормализует ws:// в http://.
livekit_url: str = "ws://localhost:7880"
# --- Пайплайн транскрибации ---
# Общий volume между LiveKit Egress и celery-воркером `transcription`
# (см. `deploy/docker-compose.yml`); в тестах переопределяется на `tmp_path`.
recordings_dir: str = "/recordings"
# --- Email (SMTP-бэкенд) ---
# `console` — дефолт для dev (письмо только логируется); `smtp` — реальная
# отправка через aiosmtplib. Секреты SMTP — только в `.env` (инвариант №6),
# переключатель бэкенда — тоже переменная окружения, а не настройка в БД
# (`instance_settings`).
email_backend: str = "console"
smtp_host: str = "localhost"
smtp_port: int = 587
smtp_username: str | None = None
smtp_password: str | None = None
smtp_start_tls: bool = True
smtp_use_tls: bool = False
smtp_from: str = "VidConf <no-reply@vidconf.example>"
smtp_timeout_s: int = 30
# --- Медиа (аватары пользователей) ---
# Каталог, куда сохраняются загруженные файлы (аватары — `avatars/{user_id}.{ext}`);
# раздаётся статикой по `/media` (`main.py`, dev) либо через nginx `location /media/`
# в проде (`deploy/nginx/nginx.conf`, volume `media`). Относительный путь по
# умолчанию — рабочая директория backend (аналог `recordings_dir`, но без
# требования root для локального запуска вне Docker).
media_root: str = "media"
# --- Автодетект железа: install.sh определяет `nproc`/`free -m`/
# `nvidia-smi` и пишет в `.env`; читает `services/ai_levels.py` для детекта
# доступности уровней AI (ADR-004) без torch/nvidia-smi внутри процесса
# backend/воркеров. `None` — install.sh не запускался (dev-окружение) либо
# GPU не обнаружен (`hw_gpu_name`/`hw_vram_mb`).
hw_cpus: int | None = None
hw_ram_mb: int | None = None
hw_gpu_name: str | None = None
hw_vram_mb: int | None = None
# --- Матрица «пресет → настройки» инсталлятора: install.sh пишет эти три
# переменные в `.env` по выбранному пресету (15), lifespan backend
# передаёт их бутстрапу `instance_settings` (`services/instance_settings.py`,
# `bootstrap_overrides_from_settings`) как overrides дефолтов
# `plugins.yaml` — БЕЗ этого механизма бутстрап всегда включал чат и
# AI-модули независимо от пресета. `None` — install.sh не запускался
# (dev-окружение) либо переменная не установлена для этого пресета:
# бутстрап тогда использует дефолты `plugins.yaml` как раньше.
bootstrap_chat_enabled: bool | None = None
bootstrap_transcription_enabled: bool | None = None
bootstrap_ai_level: AiLevel | None = None
@field_validator(
"hw_cpus",
"hw_ram_mb",
"hw_gpu_name",
"hw_vram_mb",
"bootstrap_chat_enabled",
"bootstrap_transcription_enabled",
"bootstrap_ai_level",
mode="before",
)
@classmethod
def _empty_hw_string_to_none(cls, value: object) -> object:
"""Пустая строка env (`KEY=`, а не отсутствие переменной) → `None`.
`docker-compose` подставляет `env_file` дословно: `HW_VRAM_MB=` в `.env`
(пишет `install.sh` на любой машине без NVIDIA GPU, пресеты 14;
`.env.example` — все четыре `HW_*` пустыми по умолчанию) превращается в
переменную окружения со значением `""`, а не в отсутствующую переменную —
без этой нормализации pydantic не парсит `""` как `int` и роняет
`Settings()` уже на импорте модуля (`main.py`, `workers/celery_app.py`),
не давая контейнеру стартовать. Та же проблема для `BOOTSTRAP_*`
(`.env.example` — пустыми по умолчанию, install.sh заполняет по пресету).
"""
if value == "":
return None
return value
@lru_cache
def get_settings() -> Settings:
"""Вернуть кэшированный экземпляр `Settings`."""
return Settings()

24
backend/core/db.py Normal file
View File

@@ -0,0 +1,24 @@
"""Настройка асинхронного движка SQLAlchemy и сеанса."""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from core.config import get_settings
settings = get_settings()
engine: AsyncEngine = create_async_engine(settings.database_url, pool_pre_ping=True)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
"""Зависимость FastAPI, возвращающая `AsyncSession`."""
async with async_session_maker() as session:
yield session

View File

@@ -0,0 +1,12 @@
"""Пакет плагинов Transcriber/Summarizer (Strategy + Factory).
Импорт конкретных реализаций здесь регистрирует их в `core.plugins.factory`
через декораторы `@register_transcriber`/`@register_summarizer` (побочный
эффект импорта модуля). Новая реализация = новый класс + импорт в этом
файле + строка в `config/plugins.yaml` — ядро (`factory.py`, контракты) не
трогаем.
"""
from core.plugins import faster_whisper as faster_whisper # noqa: F401
from core.plugins import null as null # noqa: F401
from core.plugins import qwen_local as qwen_local # noqa: F401

View File

@@ -0,0 +1,84 @@
"""Модели Pydantic для описания `config/plugins.yaml` и его загрузчика."""
from pathlib import Path
from typing import Any, Literal
import yaml
from pydantic import BaseModel, Field
class TranscriberConfig(BaseModel):
"""Конфигурация активного плагина transcriber."""
enabled: bool = True
provider: str = Field(default="null", min_length=1)
model: str | None = None
language: str = "ru"
options: dict[str, Any] = Field(default_factory=dict)
class SummarizerConfig(BaseModel):
"""Конфигурация активного плагина summarizer."""
enabled: bool = True
provider: str = Field(default="null", min_length=1)
model: str | None = None
chunk_minutes: int = 20
options: dict[str, Any] = Field(default_factory=dict)
class ChatConfig(BaseModel):
"""Конфигурация переключателя функции чата."""
enabled: bool = True
class PluginsConfig(BaseModel):
"""Корневая модель конфигурации для `config/plugins.yaml`."""
transcriber: TranscriberConfig = Field(default_factory=TranscriberConfig)
summarizer: SummarizerConfig = Field(default_factory=SummarizerConfig)
chat: ChatConfig = Field(default_factory=ChatConfig)
def load_plugins_config(path: str | Path) -> PluginsConfig:
"""Загрузить и валидировать `PluginsConfig` из YAML файла."""
raw = yaml.safe_load(Path(path).read_text()) or {}
return PluginsConfig.model_validate(raw)
# --- Настройки инстанса: БД поверх дефолтов `plugins.yaml`. ---
# Ключи `instance_settings` зеркалят секции ниже (`transcriber`, `summarizer`,
# `chat`, `ai_level`, `summary_recipients`, `display_timezone`) — см.
# `services/instance_settings.py`.
AiLevel = Literal["min", "medium", "max"]
"""Уровень AI-модуля инстанса (модели/требования — ADR-004,
`docs/architecture/adr/004-ai-tier-matrix.md`, `services/ai_tiers.TIERS`).
Доступность каждого уровня на конкретном инстансе зависит от обнаруженного
железа и скачанных моделей — см. `services/ai_levels.py::detect_ai_levels`."""
SummaryRecipientsMode = Literal["all", "owner"]
"""Режим рассылки саммари по умолчанию: всем участникам либо только
владельцу конференции (переопределяется на уровне `conferences.summary_recipients`)."""
class InstanceConfig(BaseModel):
"""Эффективная конфигурация инстанса (значения `instance_settings` поверх дефолтов
`plugins.yaml`, см. `services/instance_settings.py::load_effective_config`)."""
transcriber: TranscriberConfig
summarizer: SummarizerConfig
chat: ChatConfig
ai_level: AiLevel = "min"
summary_recipients: SummaryRecipientsMode = "all"
display_timezone: str = "Europe/Moscow"
# Разрешить выбор команды на форме регистрации (справочник `teams`)
# — см. `services/instance_settings.py`.
registration_team_choice: bool = False
# Верификация регистрирующихся по домену email: при включении
# `POST /auth/register` принимает только
# email с доменом `registration_email_domain` — см.
# `services/instance_settings.py`.
registration_email_domain_enabled: bool = False
registration_email_domain: str | None = None

View File

@@ -0,0 +1,47 @@
"""Factory + реестр для реализаций плагинов Transcriber/Summarizer."""
from core.plugins.config import SummarizerConfig, TranscriberConfig
from core.plugins.summarizer import Summarizer
from core.plugins.transcriber import Transcriber
class PluginError(Exception):
"""Базовая ошибка при сбое реестра/factory плагинов."""
class UnknownProviderError(PluginError):
"""Вызывается, когда запрошенный `provider` плагина не зарегистрирован."""
_TRANSCRIBERS: dict[str, type[Transcriber]] = {}
_SUMMARIZERS: dict[str, type[Summarizer]] = {}
def register_transcriber[T: type[Transcriber]](cls: T) -> T:
"""Зарегистрировать подкласс `Transcriber` под его ключом `provider`."""
_TRANSCRIBERS[cls.provider] = cls
return cls
def register_summarizer[S: type[Summarizer]](cls: S) -> S:
"""Зарегистрировать подкласс `Summarizer` под его ключом `provider`."""
_SUMMARIZERS[cls.provider] = cls
return cls
def create_transcriber(cfg: TranscriberConfig) -> Transcriber:
"""Инстанцировать `Transcriber`, зарегистрированный для `cfg.provider`."""
try:
cls = _TRANSCRIBERS[cfg.provider]
except KeyError as exc:
raise UnknownProviderError(f"Неизвестный провайдер transcriber: {cfg.provider!r}") from exc
return cls(model=cfg.model, language=cfg.language, **cfg.options) # type: ignore[call-arg]
def create_summarizer(cfg: SummarizerConfig) -> Summarizer:
"""Инстанцировать `Summarizer`, зарегистрированный для `cfg.provider`."""
try:
cls = _SUMMARIZERS[cfg.provider]
except KeyError as exc:
raise UnknownProviderError(f"Неизвестный провайдер summarizer: {cfg.provider!r}") from exc
return cls(model=cfg.model, chunk_minutes=cfg.chunk_minutes, **cfg.options) # type: ignore[call-arg]

View File

@@ -0,0 +1,141 @@
"""Плагины `Transcriber` на основе faster-whisper: CPU (`min`) и GPU (`medium`/`max`).
Оба плагина используют встроенный в faster-whisper Silero VAD (`vad_filter=True`)
и дополнительно отбрасывают сегменты короче `MIN_SEGMENT_DURATION_S` —
типичные галлюцинации Whisper на тишине/шуме (ТЗ §1.4). Общая логика
(ленивая загрузка модели-синглтона процесса, вызов `transcribe` с VAD,
фильтрация коротких сегментов) вынесена в `_FasterWhisperBase`; CPU/GPU-варианты
отличаются только параметрами устройства/квантизации (ADR-004,
`docs/architecture/adr/004-ai-tier-matrix.md`).
"""
from typing import TYPE_CHECKING, ClassVar
from core.plugins.factory import register_transcriber
from core.plugins.transcriber import Segment, Transcriber
if TYPE_CHECKING:
# Импорт только для проверки типов: рантайм-импорт — ленивый, см. `_get_model`,
# чтобы API-процесс, где транскрибация не используется, не тянул тяжёлую
# зависимость (ctranslate2 и т.п.) в память.
from faster_whisper import WhisperModel
MIN_SEGMENT_DURATION_S = 0.3
"""Минимальная длительность сегмента (сек); короче — отбрасывается как
вероятная галлюцинация Whisper на тишине/шуме."""
VAD_MIN_SILENCE_DURATION_MS = 500
"""Порог Silero VAD (мс) для разбиения на речевые куски внутри трека."""
class _FasterWhisperBase(Transcriber):
"""Общая логика плагинов faster-whisper: синглтон модели процесса + VAD-транскрибация.
Модель-синглтон принадлежит конкретному подклассу (`FasterWhisperCPU`,
`FasterWhisperGPU`), а не общему базовому классу: присваивание
`cls._model = ...` в `_get_model` всегда происходит через `type(self)`,
поэтому у каждого подкласса — свой атрибут класса, и CPU/GPU-плагины не
делят один кэшированный инстанс модели, даже если оба сконфигурированы в
одном процессе.
"""
MIN_SEGMENT_S: ClassVar[float] = MIN_SEGMENT_DURATION_S
_model: "ClassVar[WhisperModel | None]" = None
# Задаются наследниками в `__init__` (device — фиксированно классом,
# compute_type — либо фиксированно, либо конструкторская опция).
device: str
compute_type: str
def __init__(
self,
model: str,
language: str = "ru",
download_root: str | None = None,
) -> None:
self.model_name = model
self.language = language
self.download_root = download_root
def _get_model(self) -> "WhisperModel":
"""Лениво создать (или переиспользовать) синглтон `WhisperModel` конкретного подкласса."""
cls = type(self)
if cls._model is None:
from faster_whisper import WhisperModel # ленивый импорт тяжёлой зависимости
cls._model = WhisperModel(
self.model_name,
device=self.device,
compute_type=self.compute_type,
download_root=self.download_root,
)
return cls._model
def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]:
"""Транскрибировать аудиофайл трека, отбросив короткие сегменты-галлюцинации.
VAD (Silero, встроен в faster-whisper) включён с порогом тишины
`VAD_MIN_SILENCE_DURATION_MS`; дополнительно отбрасываются сегменты
короче `MIN_SEGMENT_DURATION_S`.
"""
model = self._get_model()
raw_segments, _info = model.transcribe(
audio_path,
language=language,
vad_filter=True,
vad_parameters={"min_silence_duration_ms": VAD_MIN_SILENCE_DURATION_MS},
)
return [
Segment(start=segment.start, end=segment.end, text=segment.text)
for segment in raw_segments
if (segment.end - segment.start) >= MIN_SEGMENT_DURATION_S
]
@register_transcriber
class FasterWhisperCPU(_FasterWhisperBase):
"""Транскрибер faster-whisper (CTranslate2) на CPU с int8-квантизацией (уровень `min`).
Модель — синглтон на процесс: создаётся лениво при первом вызове
`transcribe` и переиспользуется всеми последующими вызовами в рамках
одного процесса воркера (процесс запускается
в Celery-очереди `transcription` с `--pool=solo --concurrency=1`, поэтому
гонок за атрибут класса не возникает).
"""
provider: ClassVar[str] = "faster_whisper_cpu"
_model: "ClassVar[WhisperModel | None]" = None
def __init__(
self,
model: str | None = None,
language: str = "ru",
download_root: str | None = None,
) -> None:
super().__init__(model=model or "small", language=language, download_root=download_root)
self.device = "cpu"
self.compute_type = "int8"
@register_transcriber
class FasterWhisperGPU(_FasterWhisperBase):
"""Транскрибер faster-whisper на GPU (CUDA, уровни `medium`/`max`, ADR-004).
`compute_type` — конструкторская опция (дефолт `float16`, как в матрице
ADR-004); для экономии VRAM конфиг уровня может задать `int8_float16`
(options плагина в `TIERS`/`plugins.yaml`).
"""
provider: ClassVar[str] = "faster_whisper_gpu"
_model: "ClassVar[WhisperModel | None]" = None
def __init__(
self,
model: str | None = None,
language: str = "ru",
download_root: str | None = None,
compute_type: str = "float16",
) -> None:
super().__init__(model=model or "medium", language=language, download_root=download_root)
self.device = "cuda"
self.compute_type = compute_type

View File

@@ -0,0 +1,36 @@
"""No-op реализации Transcriber/Summarizer, используемые как безопасный default."""
from typing import Any, ClassVar
from core.plugins.factory import register_summarizer, register_transcriber
from core.plugins.summarizer import Summarizer
from core.plugins.transcriber import Segment, Transcriber
@register_transcriber
class NullTranscriber(Transcriber):
"""Transcriber, который не выдаёт сегменты; используется когда транскрибация отключена."""
provider: ClassVar[str] = "null"
def __init__(self, model: str | None = None, language: str = "ru", **options: Any) -> None:
self.model = model
self.language = language
self.options = options
def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]:
return []
@register_summarizer
class NullSummarizer(Summarizer):
"""Summarizer, который выдаёт пустое резюме; используется когда суммаризация отключена."""
provider: ClassVar[str] = "null"
def __init__(self, model: str | None = None, **options: Any) -> None:
self.model = model
self.options = options
def summarize(self, transcript: str) -> str:
return ""

View File

@@ -0,0 +1,203 @@
"""Плагин `Summarizer` на локальной модели семейства Qwen через сервер llama.cpp.
Конкретная модель/квант не зашиты в плагине — их задаёт конфигурация
(`config/plugins.yaml` либо `TierSpec` в `services/ai_tiers.py`, ADR-004);
дефолт конструктора (`qwen2.5-3b-instruct-q4_k_m`) — только фолбэк на случай
прямого создания плагина без конфига.
Map-reduce целиком инкапсулирован в плагине (контракт `Summarizer.summarize`
не меняется — ТЗ §1.3): транскрипт делится на чанки
чистой функцией `chunk_transcript`, каждый чанк резюмируется отдельным
вызовом LLM (map), частичные резюме объединяются одним reduce-вызовом; если
частичные резюме суммарно не влезают в бюджет токенов запроса — reduce
выполняется иерархически, группами, пока не останется одно резюме.
`max_tokens_map`/`max_tokens_reduce` — раздельные per-tier лимиты генерации
(ADR-004: reduce всегда ≥ map — 1024 токенов на reduce не хватает).
Тексты промптов (`workers/summarizer/prompts/summary_map_ru.txt`,
`summary_reduce_ru.txt`) утверждены и не меняются в коде — загружаются
лениво из файлов. Подстановка плейсхолдера — через `str.replace`, а не
`str.format`: промпты содержат разметку формата вывода (`[что решено] —
ответственный: [имя]` и т.п.) с квадратными, но потенциально и фигурными
скобками в будущих правках текста — `str.format` на них падает с
`KeyError`/`IndexError`, тогда как `str.replace` нечувствителен к остальному
содержимому файла.
"""
from pathlib import Path
from typing import Any, ClassVar
from core.plugins.factory import register_summarizer
from core.plugins.summarizer import Summarizer
from core.summarization.chunking import chunk_transcript
from core.summarization.llm_client import OpenAICompatClient
from core.summarization.tokens import QwenTokenCounter
_DEFAULT_PROMPTS_DIR = "workers/summarizer/prompts"
_MAP_PROMPT_FILE = "summary_map_ru.txt"
_REDUCE_PROMPT_FILE = "summary_reduce_ru.txt"
_MAP_PLACEHOLDER = "{transcript_chunk}"
_REDUCE_PLACEHOLDER = "{partial_summaries}"
_REDUCE_BUDGET_TOKENS = 6000
"""Бюджет токенов на один reduce-вызов (частичные резюме + шаблон промпта);
меньше `max_chunk_tokens` чанкера — запас под текст самого reduce-промпта и
вывод модели в общем контексте (CTX_SIZE=16384)."""
@register_summarizer
class QwenLocal(Summarizer):
"""Summarizer на Qwen2.5-3B-Instruct через OpenAI-совместимый сервер llama.cpp."""
provider: ClassVar[str] = "qwen_local"
def __init__(
self,
model: str | None = None,
chunk_minutes: int = 20,
base_url: str = "http://llm:8080/v1",
tokenizer_path: str = "/models/qwen/tokenizer.json",
prompts_dir: str = _DEFAULT_PROMPTS_DIR,
temperature: float = 0.2,
max_tokens: int = 1024,
max_tokens_map: int | None = None,
max_tokens_reduce: int | None = None,
**options: Any,
) -> None:
self.model = model or "qwen2.5-3b-instruct-q4_k_m"
self.chunk_minutes = chunk_minutes
self.base_url = base_url
self.tokenizer_path = tokenizer_path
self.prompts_dir = prompts_dir
self.temperature = temperature
self.max_tokens = max_tokens
# Раздельные лимиты map/reduce (ADR-004, per-tier параметры генерации);
# без явного значения оба используют общий `max_tokens` — обратная
# совместимость со старым форматом конфигурации.
self.max_tokens_map = max_tokens_map if max_tokens_map is not None else max_tokens
self.max_tokens_reduce = max_tokens_reduce if max_tokens_reduce is not None else max_tokens
self.options = options
self._count_tokens = QwenTokenCounter(tokenizer_path)
self._client: OpenAICompatClient | None = None
self._map_prompt: str | None = None
self._reduce_prompt: str | None = None
def _get_client(self) -> OpenAICompatClient:
"""Лениво создать HTTP-клиент LLM (переиспользуется в рамках инстанса плагина)."""
if self._client is None:
self._client = OpenAICompatClient(
base_url=self.base_url,
model=self.model,
temperature=self.temperature,
max_tokens=self.max_tokens,
**self.options,
)
return self._client
def close(self) -> None:
"""Закрыть HTTP-клиент LLM, если он был лениво создан (освободить пул соединений).
Безопасно вызывать многократно и до первого использования — если
клиент ни разу не создавался, ничего не делает.
"""
if self._client is not None:
self._client.close()
self._client = None
def _load_prompt(self, filename: str) -> str:
"""Прочитать текст промпта из `prompts_dir` (без изменений, как есть на диске)."""
return (Path(self.prompts_dir) / filename).read_text(encoding="utf-8")
def _map_prompt_template(self) -> str:
if self._map_prompt is None:
self._map_prompt = self._load_prompt(_MAP_PROMPT_FILE)
return self._map_prompt
def _reduce_prompt_template(self) -> str:
if self._reduce_prompt is None:
self._reduce_prompt = self._load_prompt(_REDUCE_PROMPT_FILE)
return self._reduce_prompt
def _map_chunk(self, chunk: str) -> str:
"""Выполнить map-вызов LLM для одного чанка транскрипта."""
prompt = self._map_prompt_template().replace(_MAP_PLACEHOLDER, chunk)
return self._get_client().complete(prompt, max_tokens=self.max_tokens_map)
def _reduce_once(self, summaries: list[str]) -> str:
"""Выполнить один reduce-вызов LLM над группой частичных резюме."""
joined = "\n\n".join(summaries)
prompt = self._reduce_prompt_template().replace(_REDUCE_PLACEHOLDER, joined)
return self._get_client().complete(prompt, max_tokens=self.max_tokens_reduce)
def _group_by_token_budget(self, summaries: list[str], budget: int) -> list[list[str]]:
"""Жадно сгруппировать резюме так, чтобы каждая группа влезала в `budget` токенов."""
groups: list[list[str]] = []
current: list[str] = []
current_tokens = 0
for summary in summaries:
tokens = self._count_tokens(summary)
if current and current_tokens + tokens > budget:
groups.append(current)
current = []
current_tokens = 0
current.append(summary)
current_tokens += tokens
if current:
groups.append(current)
return groups
def _reduce(self, partial_summaries: list[str]) -> str:
"""Свести частичные резюме к одному, иерархически группами при переполнении бюджета.
Группировка по токенам (`_group_by_token_budget`) не гарантирует
прогресс, если отдельные частичные резюме сами не помещаются в
`_REDUCE_BUDGET_TOKENS` (например, при неудачно большом `max_tokens`
в конфиге плагина) — тогда она вырождается в список синглтон-групп,
и список резюме не сокращается. В этом случае принудительно сводим
резюме попарно: длина списка минимум делится пополам на каждой
итерации, что гарантирует завершение цикла за конечное число шагов.
"""
summaries = partial_summaries
while len(summaries) > 1:
joined_tokens = self._count_tokens("\n\n".join(summaries))
if joined_tokens <= _REDUCE_BUDGET_TOKENS:
return self._reduce_once(summaries)
groups = self._group_by_token_budget(summaries, _REDUCE_BUDGET_TOKENS)
if len(groups) >= len(summaries):
# Группировка по бюджету не уменьшила число групп (каждое
# резюме — уже отдельная группа) — гарантируем прогресс
# принудительным объединением попарно.
groups = [summaries[i : i + 2] for i in range(0, len(summaries), 2)]
summaries = [self._reduce_once(group) for group in groups]
return summaries[0]
def summarize(self, transcript: str) -> str:
"""Построить резюме транскрипта: map по чанкам, затем reduce до одного текста.
Пустой транскрипт (пустой список чанков) — пустая строка без вызовов
LLM. Единственный чанк — map-результат уже соответствует формату
reduce-вывода, дополнительный reduce-вызов не требуется.
HTTP-клиент LLM (если он был создан) закрывается по завершении вызова
независимо от исхода — плагин инстанцируется на одну задачу
суммаризации (см. `create_summarizer` в фабрике), поэтому держать
пул соединений открытым дольше одного вызова `summarize` не нужно.
"""
try:
chunks = chunk_transcript(
transcript,
self._count_tokens,
target_chunk_minutes=self.chunk_minutes,
)
if not chunks:
return ""
partial_summaries = [self._map_chunk(chunk) for chunk in chunks]
if len(partial_summaries) == 1:
return partial_summaries[0]
return self._reduce(partial_summaries)
finally:
self.close()

View File

@@ -0,0 +1,15 @@
"""Контракт плагина Summarizer."""
from abc import ABC, abstractmethod
from typing import ClassVar
class Summarizer(ABC):
"""Интерфейс Strategy для реализаций суммаризации текста."""
provider: ClassVar[str]
@abstractmethod
def summarize(self, transcript: str) -> str:
"""Создать резюме переданной трансцрибции."""
...

View File

@@ -0,0 +1,25 @@
"""Контракт плагина Transcriber."""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import ClassVar
@dataclass(frozen=True, slots=True)
class Segment:
"""Один транскрибированный сегмент трека."""
start: float # секунды от начала трека
end: float
text: str
class Transcriber(ABC):
"""Интерфейс Strategy для реализаций преобразования речи в текст."""
provider: ClassVar[str]
@abstractmethod
def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]:
"""Транскрибировать аудиофайл по пути `audio_path` в список сегментов."""
...

View File

@@ -0,0 +1,36 @@
"""Rate limit на основе Redis `INCR`+`EXPIRE` для публичных (без auth) эндпоинтов.
Используется резолвом конференций и гостевым входом (`api/conferences.py`) —
эндпоинтами без аутентификации, уязвимыми к перебору номера/ссылки конференции
(см. ADR-001, п.4 — оценка энтропии и рекомендуемый лимит 10 запросов/мин на IP).
"""
from fastapi import HTTPException, status
from core.redis import redis_client
RATE_LIMIT_MAX_REQUESTS = 10
RATE_LIMIT_WINDOW_SECONDS = 60
async def enforce_rate_limit(
key: str,
*,
max_requests: int = RATE_LIMIT_MAX_REQUESTS,
window_seconds: int = RATE_LIMIT_WINDOW_SECONDS,
) -> None:
"""Увеличить счётчик запросов по ключу; бросить 429, если лимит превышен.
`INCR` атомарно создаёт ключ со значением 1, если его ещё не было; TTL
выставляется только при первом инкременте в окне (когда счётчик стал
равен 1) — иначе окно продлевалось бы при каждом запросе и лимит
никогда бы не истекал.
"""
redis_key = f"rate_limit:{key}"
current = await redis_client.incr(redis_key)
if current == 1:
await redis_client.expire(redis_key, window_seconds)
if current > max_requests:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="rate_limit_exceeded"
)

9
backend/core/redis.py Normal file
View File

@@ -0,0 +1,9 @@
"""Настройка асинхронного Redis клиента."""
from redis.asyncio import Redis
from core.config import get_settings
settings = get_settings()
redis_client: Redis = Redis.from_url(settings.redis_url, decode_responses=True)

71
backend/core/security.py Normal file
View File

@@ -0,0 +1,71 @@
"""Хэширование паролей (argon2) и выпуск/проверка JWT (access + refresh)."""
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from core.config import get_settings
JWT_ALGORITHM = "HS256"
_hasher = PasswordHasher()
def hash_password(password: str) -> str:
"""Захэшировать пароль алгоритмом argon2 для хранения в БД."""
return _hasher.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
"""Сверить пароль с сохранённым argon2-хэшем; пароль/хэш никогда не логируются."""
try:
return _hasher.verify(password_hash, password)
except VerifyMismatchError:
return False
def create_access_token(user_id: uuid.UUID, role: str) -> str:
"""Выпустить access-токен: `sub`=user_id, `role`=роль, TTL из настроек."""
settings = get_settings()
now = datetime.now(UTC)
payload = {
"sub": str(user_id),
"role": role,
"type": "access",
"iat": now,
"exp": now + timedelta(minutes=settings.access_token_ttl_minutes),
}
return jwt.encode(payload, settings.jwt_secret, algorithm=JWT_ALGORITHM)
def create_refresh_token(user_id: uuid.UUID) -> tuple[str, str]:
"""Выпустить refresh-токен с уникальным `jti`.
Возвращает пару (token, jti); сохранение jti в Redis — ответственность
вызывающего кода (`services.auth.AuthService`).
"""
settings = get_settings()
jti = str(uuid.uuid4())
now = datetime.now(UTC)
payload = {
"sub": str(user_id),
"jti": jti,
"type": "refresh",
"iat": now,
"exp": now + timedelta(days=settings.refresh_token_ttl_days),
}
token = jwt.encode(payload, settings.jwt_secret, algorithm=JWT_ALGORITHM)
return token, jti
def decode_token(token: str) -> dict[str, Any]:
"""Декодировать и верифицировать JWT (сигнатура + срок действия).
Бросает `jwt.PyJWTError` (или подкласс) при невалидном/просроченном токене.
"""
settings = get_settings()
return jwt.decode(token, settings.jwt_secret, algorithms=[JWT_ALGORITHM])

View File

@@ -0,0 +1,5 @@
"""Пакет чистых функций суммаризации: чанкинг транскрипта и подсчёт токенов.
Плагин `QwenLocal` использует эти функции как строительные
блоки map-reduce; сам пакет не знает про LLM и HTTP.
"""

View File

@@ -0,0 +1,156 @@
"""Чанкинг транскрипта для map-стадии суммаризации (ТЗ §1.3).
Транскрипт — строка с одной фразой на строку в формате `[Имя MM:SS] текст`
(или `[Имя ЧЧ:MM:SS] текст` от часа, см. `workers/summarizer/transcript.py`).
Каждая строка уже атомарна и соответствует одной реконструированной фразе
(`build_phrases`) — граница фразы там уже равна смене спикера,
поэтому `chunk_transcript` закрывает чанк исключительно на границах строк и
никогда не режет фразу пополам (кроме аварийного случая монолога, см. ниже).
Правила закрытия чанка (проверяются перед добавлением очередной фразы):
- добавление фразы сделало бы охваченный чанком промежуток времени
(от начала чанка до начала этой фразы) >= `target_chunk_minutes`;
- ЛИБО добавление фразы превысило бы `max_chunk_tokens` для чанка.
В любом из этих случаев уже накопленный чанк закрывается, а фраза уходит в
новый чанк.
Аварийный случай — монолог: единственная фраза сама по себе превышает
`max_chunk_tokens` (например, 40-минутный монолог одного участника). Такую
фразу нельзя оставить целой и нельзя просто выбросить — она делится по
границам предложений на несколько частей меньше лимита, каждая из которых
получает повторённую исходную метку `[Имя MM:SS]`, и добавляется в список
чанков как самостоятельный чанк.
"""
import re
from collections.abc import Callable
_LABEL_RE = re.compile(r"^\[(?P<speaker>.+) (?P<time>\d{1,3}:\d{2}(?::\d{2})?)\] (?P<text>.*)$")
"""Метка фразы: `[Имя MM:SS]` или `[Имя ЧЧ:MM:SS]`; имя может содержать пробелы."""
_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+")
"""Граница предложения — пробел после `.`, `!` или `?`."""
def _parse_offset_seconds(time_str: str) -> float:
"""Разобрать метку времени `MM:SS` или `ЧЧ:MM:SS` в секунды от начала сеанса."""
parts = [int(part) for part in time_str.split(":")]
if len(parts) == 2:
minutes, seconds = parts
return float(minutes * 60 + seconds)
hours, minutes, seconds = parts
return float(hours * 3600 + minutes * 60 + seconds)
def _line_offset(line: str) -> float:
"""Извлечь смещение начала фразы (в секундах) из метки строки.
Строка без распознаваемой метки считается стоящей в начале сеанса
(0.0) — чанкер не должен падать на нестандартном входе, только терять
точность деления по времени.
"""
match = _LABEL_RE.match(line)
if match is None:
return 0.0
return _parse_offset_seconds(match.group("time"))
def _split_sentences(text: str) -> list[str]:
"""Разбить текст фразы на предложения; пустой текст даёт список из одного элемента."""
sentences = [s.strip() for s in _SENTENCE_SPLIT_RE.split(text) if s.strip()]
return sentences or [text]
def _split_monologue(
line: str,
count_tokens: Callable[[str], int],
max_chunk_tokens: int,
) -> list[str]:
"""Аварийно разделить фразу-монолог, превышающую лимит, по предложениям.
Метка спикера повторяется в каждой части. Если строка не распознана как
фраза с меткой (не должно случаться при штатном входе из
`build_transcript`), делить не на чем — возвращаем строку одним чанком,
даже с превышением лимита.
"""
match = _LABEL_RE.match(line)
if match is None:
return [line]
label = f"[{match.group('speaker')} {match.group('time')}]"
sentences = _split_sentences(match.group("text"))
pieces: list[str] = []
current: list[str] = []
for sentence in sentences:
candidate = f"{label} {' '.join([*current, sentence])}"
if current and count_tokens(candidate) > max_chunk_tokens:
pieces.append(f"{label} {' '.join(current)}")
current = [sentence]
else:
current.append(sentence)
if current:
pieces.append(f"{label} {' '.join(current)}")
return pieces
def chunk_transcript(
transcript: str,
count_tokens: Callable[[str], int],
*,
max_chunk_tokens: int = 8000,
target_chunk_minutes: int = 20,
) -> list[str]:
"""Разбить транскрипт на чанки для map-стадии суммаризации.
Аргументы:
transcript: строки-фразы `[Имя MM:SS] текст`, по одной на строку.
count_tokens: подсчёт токенов для строки/чанка (в проде —
`QwenTokenCounter`, в тестах — фейковый callable).
max_chunk_tokens: верхняя граница токенов на чанк (ТЗ: 38k).
target_chunk_minutes: целевая длительность чанка в минутах записи.
Возвращает список строк-чанков (без изменения содержимого фраз внутри),
пустой список для пустого транскрипта.
"""
lines = [line for line in transcript.splitlines() if line.strip()]
if not lines:
return []
target_seconds = target_chunk_minutes * 60
chunks: list[str] = []
current_lines: list[str] = []
current_tokens = 0
chunk_start_offset = 0.0
def flush() -> None:
nonlocal current_lines, current_tokens
if current_lines:
chunks.append("\n".join(current_lines))
current_lines = []
current_tokens = 0
for line in lines:
line_tokens = count_tokens(line)
if line_tokens > max_chunk_tokens:
# монолог одной фразой больше лимита — аварийное деление по предложениям
flush()
chunks.extend(_split_monologue(line, count_tokens, max_chunk_tokens))
continue
if current_lines:
offset = _line_offset(line)
prospective_span = offset - chunk_start_offset
prospective_tokens = current_tokens + line_tokens
if prospective_tokens > max_chunk_tokens or prospective_span >= target_seconds:
flush()
if not current_lines:
chunk_start_offset = _line_offset(line)
current_lines.append(line)
current_tokens += line_tokens
flush()
return chunks

View File

@@ -0,0 +1,155 @@
"""HTTP-клиент к OpenAI-совместимому LLM-серверу (llama.cpp server, ТЗ §3.3).
`llama.cpp server` (образ `ghcr.io/ggml-org/llama.cpp:server`) отдаёт
`/v1/chat/completions` в формате OpenAI Chat Completions API: тело запроса
`{"model": ..., "messages": [{"role": "user", "content": ...}]}`, ответ —
`choices[0].message.content` (см. `tools/server/README.md` проекта llama.cpp).
Надёжность: retry с экспоненциальным backoff на
сетевых ошибках и retryable HTTP-статусах (5xx, включая 503 — модель ещё
грузится), плюс circuit breaker поверх retry — после `breaker_threshold`
подряд неудачных вызовов `complete()` окно `breaker_cooldown_s` секунд все
вызовы падают немедленно с `LlmUnavailableError`, не делая HTTP-запросов.
"""
import logging
import time
from typing import Any
import httpx
logger = logging.getLogger(__name__)
_INITIAL_BACKOFF_S = 0.5
"""Базовая пауза перед повтором; растёт экспоненциально: `_INITIAL_BACKOFF_S * 2**attempt`."""
_RETRYABLE_STATUS_CODES = frozenset({503})
"""Дополнительные ретраибл-статусы помимо диапазона 5xx (503 — модель llama.cpp ещё грузится)."""
class LlmUnavailableError(Exception):
"""LLM-сервер недоступен: исчерпаны попытки retry либо открыт circuit breaker."""
class OpenAICompatClient:
"""Клиент чат-комплишенов OpenAI-совместимого сервера (llama.cpp, Ollama и т.п.).
`transport` — точка внедрения `httpx.MockTransport` в тестах; в проде не
передаётся (используется реальный сетевой транспорт `httpx`).
"""
def __init__(
self,
base_url: str,
model: str,
*,
temperature: float = 0.2,
max_tokens: int = 1024,
timeout_s: float = 600.0,
max_attempts: int = 3,
breaker_threshold: int = 5,
breaker_cooldown_s: float = 60.0,
transport: httpx.BaseTransport | None = None,
) -> None:
self._base_url = base_url.rstrip("/")
self._model = model
self._temperature = temperature
self._max_tokens = max_tokens
self._max_attempts = max_attempts
self._breaker_threshold = breaker_threshold
self._breaker_cooldown_s = breaker_cooldown_s
self._client = httpx.Client(timeout=timeout_s, transport=transport)
self._consecutive_failures = 0
self._breaker_open_until = 0.0
def complete(self, prompt: str, *, max_tokens: int | None = None) -> str:
"""Выполнить чат-комплишн по одному пользовательскому сообщению `prompt`.
`max_tokens` — переопределение лимита на конкретный вызов (ADR-004:
раздельные лимиты map/reduce у `QwenLocal`); по умолчанию берётся
`max_tokens`, заданный в конструкторе клиента.
Бросает `LlmUnavailableError`, если circuit breaker открыт (окно
отказа ещё не истекло) либо все попытки retry исчерпаны.
"""
now = time.monotonic()
if now < self._breaker_open_until:
raise LlmUnavailableError(
"LLM-сервер недоступен: circuit breaker открыт после серии сбоев"
)
url = f"{self._base_url}/chat/completions"
payload: dict[str, Any] = {
"model": self._model,
"messages": [{"role": "user", "content": prompt}],
"temperature": self._temperature,
"max_tokens": max_tokens if max_tokens is not None else self._max_tokens,
}
last_error: Exception | None = None
for attempt in range(self._max_attempts):
try:
response = self._client.post(url, json=payload)
except httpx.TransportError as exc:
last_error = exc
logger.warning(
"Сетевая ошибка при обращении к LLM (попытка %d): %s", attempt + 1, exc
)
else:
if response.status_code == 200:
self._consecutive_failures = 0
try:
data = response.json()
content: str = data["choices"][0]["message"]["content"]
except (ValueError, KeyError, IndexError, TypeError) as exc:
last_error = exc
logger.warning("Некорректный ответ LLM-сервера: %s", exc)
else:
return content
elif response.status_code >= 500 or response.status_code in _RETRYABLE_STATUS_CODES:
last_error = RuntimeError(
f"LLM-сервер вернул retryable статус {response.status_code}"
)
logger.warning(
"Retryable статус %d от LLM (попытка %d)", response.status_code, attempt + 1
)
else:
# Не retryable статус (например, 4xx) — не тратим оставшиеся
# попытки, но фиксируем сбой для circuit breaker.
self._register_failure()
raise LlmUnavailableError(
f"LLM-сервер вернул статус {response.status_code}: {response.text}"
) from None
if attempt < self._max_attempts - 1:
time.sleep(_INITIAL_BACKOFF_S * (2**attempt))
self._register_failure()
raise LlmUnavailableError(
f"LLM-сервер недоступен после {self._max_attempts} попыток"
) from last_error
def _register_failure(self) -> None:
"""Учесть неудачный вызов `complete()`; открыть breaker при достижении порога."""
self._consecutive_failures += 1
if self._consecutive_failures >= self._breaker_threshold:
self._breaker_open_until = time.monotonic() + self._breaker_cooldown_s
logger.warning(
"Circuit breaker открыт на %.0fс после %d подряд неудач",
self._breaker_cooldown_s,
self._consecutive_failures,
)
def close(self) -> None:
"""Закрыть базовый HTTP-клиент (освободить соединения и пул `httpx`).
Безопасно вызывать более одного раза — `httpx.Client.close()` идемпотентен.
"""
self._client.close()
def __enter__(self) -> "OpenAICompatClient":
return self
def __exit__(self, *exc_info: object) -> None:
self.close()

View File

@@ -0,0 +1,51 @@
"""Подсчёт токенов для чанкера и плагина `QwenLocal`."""
import logging
from typing import Any
logger = logging.getLogger(__name__)
class QwenTokenCounter:
"""Подсчёт токенов токенизатором модели Qwen2.5-3B-Instruct.
Загрузка `tokenizers.Tokenizer` — ленивая (при первом вызове), чтобы
инстанцирование плагина в API-процессе не тянуло за собой файл
токенизатора. Если файл отсутствует или повреждён — используется
эвристический фолбэк `len(text) // 3`, чтобы пайплайн не падал из-за
отсутствия токенизатора (например, в dev-окружении без volume модели).
"""
def __init__(self, tokenizer_path: str | None = None) -> None:
self._tokenizer_path = tokenizer_path
self._tokenizer: Any | None = None
self._load_attempted = False
def _ensure_loaded(self) -> None:
"""Загрузить токенизатор один раз (синглтон на инстанс counter'а)."""
if self._load_attempted:
return
self._load_attempted = True
if not self._tokenizer_path:
logger.warning("Путь к токенизатору не задан, использую эвристику len(text) // 3")
return
try:
from tokenizers import Tokenizer
self._tokenizer = Tokenizer.from_file(self._tokenizer_path)
except Exception:
logger.warning(
"Не удалось загрузить токенизатор из %s, использую эвристику len(text) // 3",
self._tokenizer_path,
)
self._tokenizer = None
def __call__(self, text: str) -> int:
"""Вернуть число токенов в тексте (или эвристическую оценку)."""
self._ensure_loaded()
if self._tokenizer is not None:
encoded: int = len(self._tokenizer.encode(text).ids)
return encoded
return len(text) // 3

97
backend/main.py Normal file
View File

@@ -0,0 +1,97 @@
"""Точка входа FastAPI приложения."""
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from api.admin import router as admin_router
from api.auth import router as auth_router
from api.chat import router as chat_router
from api.conferences import router as conferences_router
from api.health import router as health_router
from api.livekit_webhook import router as livekit_webhook_router
from api.metrics import prometheus_latency_middleware
from api.metrics import router as metrics_router
from api.teams import router as teams_router
from api.users import router as users_router
from core.config import get_settings
from core.db import async_session_maker, engine
from core.redis import redis_client
from services.instance_settings import InstanceSettingsService, bootstrap_overrides_from_settings
def _configure_logging() -> None:
"""Настроить базовый вывод логов приложения (INFO) в stdout.
Без этого root-логгер под uvicorn остаётся без хендлера/на уровне
WARNING, и INFO-логи наших модулей (например, `services.email` —
ссылка подтверждения email в dev-режиме) никуда не попадают, включая
`docker logs`. `logging.basicConfig` идемпотентен: если хендлер на
root уже есть (в т.ч. при повторных вызовах `create_app()` в тестах),
он не добавляется повторно — дублирования строк не будет.
"""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logging.getLogger().setLevel(logging.INFO)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Бутстрап настроек инстанса из `plugins.yaml` + освобождение движка БД и Redis при завершении.
Бутстрап (`InstanceSettingsService.ensure_bootstrapped`) идемпотентен
(`INSERT ... ON CONFLICT DO NOTHING`) — безопасен при каждом рестарте
backend, уже сделанные администратором правки настроек не перетираются.
`overrides` — матрица «пресет → настройки» инсталлятора
(`BOOTSTRAP_*` в `.env`): влияет только на чистую БД
(первый запуск), см. докстринг `ensure_bootstrapped`.
"""
settings = get_settings()
overrides = bootstrap_overrides_from_settings(settings)
async with async_session_maker() as session:
await InstanceSettingsService(session).ensure_bootstrapped(
settings.plugins_config_path, overrides
)
try:
yield
finally:
await engine.dispose()
await redis_client.aclose()
def create_app() -> FastAPI:
"""Построить и настроить FastAPI приложение."""
_configure_logging()
app = FastAPI(title="VidConf API", lifespan=lifespan)
# Латентность HTTP по маршрутам — оборачивает
# ВСЕ запросы, включая сам /metrics (шаблон пути резолвится по
# `request.app.routes` в момент запроса, а не при регистрации — порядок
# относительно `include_router` ниже значения не имеет).
app.middleware("http")(prometheus_latency_middleware)
app.include_router(health_router)
app.include_router(metrics_router)
app.include_router(auth_router)
app.include_router(users_router)
app.include_router(teams_router)
app.include_router(conferences_router)
app.include_router(chat_router)
app.include_router(admin_router)
app.include_router(livekit_webhook_router)
# Статика загруженных файлов (аватары) — в проде эту же
# директорию раздаёт nginx (`location /media/`, `deploy/nginx/nginx.conf`),
# здесь — dev-режим/fallback. Каталог создаётся заранее: `StaticFiles`
# падает при монтировании несуществующей директории.
media_root = Path(get_settings().media_root)
media_root.mkdir(parents=True, exist_ok=True)
app.mount("/media", StaticFiles(directory=media_root), name="media")
return app
app = create_app()

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

115
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,115 @@
[project]
name = "backend"
version = "0.1.0"
description = "Add your description here"
requires-python = ">=3.12"
dependencies = [
"aiosmtplib>=5.1.2",
"alembic>=1.18.5",
"argon2-cffi>=25.1.0",
"asyncpg>=0.31.0",
"celery[redis]>=5.3.1",
"email-validator>=2.3.0",
"fastapi>=0.139.0",
"faster-whisper>=1.2.1",
"httpx>=0.28.1",
"icalendar>=7.2.0",
"livekit-api>=1.2.0",
"prometheus-client>=0.25.0",
"pydantic-settings>=2.14.2",
"pyjwt>=2.13.0",
"python-multipart>=0.0.32",
"pyyaml>=6.0.3",
"redis>=8.0.1",
"sqlalchemy[asyncio]>=2.0.51",
"tokenizers>=0.23.1",
"uvicorn[standard]>=0.51.0",
]
# Уровни AI `medium`(опционально)/`max` (ADR-004, docs/architecture/adr/
# 004-ai-tier-matrix.md): cuBLAS/cuDNN 9 для faster-whisper на CUDA 12
# (`WhisperModel(..., device="cuda")`, `core/plugins/faster_whisper.py`,
# класс `FasterWhisperGPU`). Ставится только в GPU-образе воркера
# транскрибации (compose-сервис `worker-transcriber-gpu`,
# профиль `transcribe-gpu`) — не в базовом CPU-образе backend/worker.
[project.optional-dependencies]
gpu = [
"nvidia-cublas-cu12",
"nvidia-cudnn-cu12==9.*",
]
[dependency-groups]
dev = [
"mypy>=2.3.0",
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
"ruff>=0.15.21",
"types-pyyaml>=6.0.12.20260518",
]
[tool.ruff]
line-length = 100
target-version = "py312"
extend-exclude = ["alembic/versions"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "ASYNC"]
ignore = ["B008"] # FastAPI's `Depends(...)` default-argument pattern is idiomatic
[tool.ruff.lint.per-file-ignores]
# Инлайн-стили HTML-письма (требование почтовых клиентов — внешний `<style>`
# большинство из них игнорирует) — длина строки не показатель качества здесь,
# оборачивать вёрстку ради line-length бессмысленно.
"services/email_templates.py" = ["E501"]
[tool.ruff.lint.isort]
# `workers/` — соседний пакет монорепо (см. `pythonpath` в
# [tool.pytest.ini_options]), а не сторонняя библиотека; без этого ruff
# постоянно перекладывает его импорт между группами first-party/third-party.
known-first-party = ["workers"]
[tool.mypy]
python_version = "3.12"
strict = true
plugins = ["pydantic.mypy"]
# `workers/` живёт вне backend/ (см. соответствующий комментарий у
# `pythonpath` в [tool.pytest.ini_options]) — добавляем корень репозитория
# в путь поиска модулей, чтобы mypy мог резолвить `import workers.*` в тестах.
mypy_path = ".."
[[tool.mypy.overrides]]
module = "livekit.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
# celery не поставляет тайп-стабы/py.typed; `@app.task` из-за этого
# нетипизирован — strict-режим backend/ на этот декоратор не распространяем.
module = "celery.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
# kombu (транспорт celery) тоже без тайп-стабов/py.typed — нужен для
# retry-обёртки postановки задач на `kombu.exceptions.OperationalError`/
# `ConnectionError` (workers/tasks/pipeline.py).
module = "kombu.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "workers.tasks.*"
disallow_untyped_decorators = false
[[tool.mypy.overrides]]
# faster-whisper не поставляет тайп-стабы/py.typed.
module = "faster_whisper.*"
ignore_missing_imports = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
testpaths = ["tests"]
# `workers/` живёт вне backend/ (репозиторий — монорепо), но
# в проде (deploy/docker-compose.yml) монтируется в тот же PYTHONPATH, что и
# backend-код. Добавляем корень репозитория в sys.path, чтобы тесты
# автоосвобождения комнат могли делать `import workers.*` так же, как в проде.
pythonpath = [".."]

View File

View File

@@ -0,0 +1,133 @@
"""Репозитории списков конференций/пользователей/команд для админ-API (пагинация, поиск)."""
import uuid
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from models.conference import Conference
from models.team import Team
from models.user import User
class AdminConferenceRepository:
"""Постраничный список конференций с фильтром по статусу и текстовым поиском."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_paginated(
self, *, status: str | None, q: str | None, limit: int, offset: int
) -> tuple[list[tuple[Conference, str | None, str | None]], int]:
"""Вернуть страницу конференций (+ имя/email владельца) и общее число совпадений.
`q` ищет по названию/номеру/ссылке (регистронезависимо, `ILIKE`).
Владелец — `LEFT JOIN` (может отсутствовать, ADR-001) — имя/email
нужны колонке «Владелец» в таблице админки.
"""
filters = []
if status is not None:
filters.append(Conference.status == status)
if q:
like = f"%{q}%"
filters.append(
or_(
Conference.title.ilike(like),
Conference.number.ilike(like),
Conference.slug.ilike(like),
)
)
count_stmt = select(func.count()).select_from(Conference)
items_stmt = (
select(Conference, User.name_user, User.email)
.outerjoin(User, Conference.owner_id == User.id)
.order_by(Conference.created_at.desc())
)
for condition in filters:
count_stmt = count_stmt.where(condition)
items_stmt = items_stmt.where(condition)
items_stmt = items_stmt.limit(limit).offset(offset)
total = (await self._session.execute(count_stmt)).scalar_one()
rows = (await self._session.execute(items_stmt)).all()
items = [(conference, name, email) for conference, name, email in rows]
return items, total
class AdminUserRepository:
"""Постраничный список пользователей с текстовым поиском по email/имени."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_paginated(
self, *, q: str | None, limit: int, offset: int
) -> tuple[list[tuple[User, str | None]], int]:
"""Вернуть страницу пользователей (+ имя команды) и общее число совпадений.
`LEFT JOIN` на `teams` — имя команды нужно карточке профиля/таблице
админки, у пользователя без команды — `None`.
"""
filters = []
if q:
like = f"%{q}%"
filters.append(or_(User.email.ilike(like), User.name_user.ilike(like)))
count_stmt = select(func.count()).select_from(User)
items_stmt = (
select(User, Team.name)
.outerjoin(Team, User.team_id == Team.id)
.order_by(User.created_at.desc())
)
for condition in filters:
count_stmt = count_stmt.where(condition)
items_stmt = items_stmt.where(condition)
items_stmt = items_stmt.limit(limit).offset(offset)
total = (await self._session.execute(count_stmt)).scalar_one()
rows = (await self._session.execute(items_stmt)).all()
return [(user, team_name) for user, team_name in rows], total
async def get_with_team(self, user_id: uuid.UUID) -> tuple[User, str | None] | None:
"""Пользователь + имя команды по id (карточка профиля); `None` — не найден."""
result = await self._session.execute(
select(User, Team.name)
.outerjoin(Team, User.team_id == Team.id)
.where(User.id == user_id)
)
row = result.first()
return (row[0], row[1]) if row is not None else None
class TeamRepository:
"""Справочник команд: список (сортировка по названию), поиск по имени, CRUD."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_all(self) -> tuple[list[Team], int]:
"""Все команды, отсортированные по названию, и их общее число."""
items_stmt = select(Team).order_by(Team.name)
items = (await self._session.execute(items_stmt)).scalars().all()
return list(items), len(items)
async def get(self, team_id: uuid.UUID) -> Team | None:
"""Найти команду по id."""
return await self._session.get(Team, team_id)
async def get_by_name(self, name: str) -> Team | None:
"""Найти команду по точному названию (проверка дубля перед созданием/переименованием)."""
result = await self._session.execute(select(Team).where(Team.name == name))
return result.scalar_one_or_none()
async def create(self, name: str) -> Team:
"""Создать команду."""
team = Team(name=name)
self._session.add(team)
await self._session.flush()
return team
async def delete(self, team: Team) -> None:
"""Удалить команду (у пользователей `team_id` обнулится через ON DELETE SET NULL)."""
await self._session.delete(team)

View File

@@ -0,0 +1,48 @@
"""Репозиторий доступа к сообщениям чата (`chat_messages`)."""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.chat import ChatMessage
class ChatMessageRepository:
"""Инкапсулирует SQL-запросы к сообщениям чата конкретной сессии конференции."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def add(
self,
*,
session_id: uuid.UUID,
user_id: uuid.UUID | None,
guest_access_id: uuid.UUID | None,
author_name: str,
text: str,
) -> ChatMessage:
"""Добавить сообщение чата и вернуть строку с проставленными `id`/`created_at`."""
message = ChatMessage(
session_id=session_id,
user_id=user_id,
guest_access_id=guest_access_id,
author_name=author_name,
text=text,
)
self._session.add(message)
await self._session.flush()
return message
async def last_for_session(self, session_id: uuid.UUID, *, limit: int) -> list[ChatMessage]:
"""Последние `limit` сообщений сессии в хронологическом порядке (от старых к новым)."""
result = await self._session.execute(
select(ChatMessage)
.where(ChatMessage.session_id == session_id)
.order_by(ChatMessage.created_at.desc())
.limit(limit)
)
rows = list(result.scalars().all())
rows.reverse()
return rows

View File

@@ -0,0 +1,543 @@
"""Репозитории доступа к `conferences` (сущность) и `conference_sessions` (сеанс, ADR-001).
`ConferenceSessionRepository` используется webhook-обработчиками LiveKit
(`services/webhook_handlers.py`) и beat-задачей обслуживания
(`workers/tasks/maintenance.py`) — методы get-or-create/guard-стиля, чтобы
быть безопасными при пропущенных или дублирующихся событиях (шаги
пост-обработки идемпотентны).
"""
import uuid
from datetime import datetime
from typing import cast
from sqlalchemy import delete, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from models.audio_track import SessionAudioTrack
from models.conference import Conference
from models.invitee import ConferenceInvitee
from models.participant import ConferenceParticipant
from models.session import ConferenceSession
from models.user import User
class ConferenceRepository:
"""Инкапсулирует SQL-запросы к конференциям (`conferences`)."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_id(self, conference_id: uuid.UUID) -> Conference | None:
"""Найти конференцию по id."""
return await self._session.get(Conference, conference_id)
async def get_by_slug(self, slug: str) -> Conference | None:
"""Найти конференцию по постоянной ссылке (= имени LiveKit-комнаты)."""
result = await self._session.execute(select(Conference).where(Conference.slug == slug))
return result.scalar_one_or_none()
async def get_by_number(self, number: str) -> Conference | None:
"""Найти конференцию по человеко-диктуемому номеру."""
result = await self._session.execute(select(Conference).where(Conference.number == number))
return result.scalar_one_or_none()
async def get_status_by_id(self, conference_id: uuid.UUID) -> str | None:
"""Прочитать АКТУАЛЬНЫЙ статус конференции, минуя identity map сессии.
В отличие от `get_by_id`/`session.get(...)`, SELECT одной колонки не
возвращает уже загруженный в эту сессию ORM-объект `Conference` из
кэша identity map — а при `expire_on_commit=False` (`core/db.py`)
такой объект, однажды загруженный долгоживущей WS-сессией чата,
никогда сам не увидит статус, изменённый вебхуком в ДРУГОЙ сессии/
процессе (например, `room_finished` -> `ended`). Используется
`ChatService.persist_and_publish` перед созданием новой сессии
пайплайна.
"""
result = await self._session.execute(
select(Conference.status).where(Conference.id == conference_id)
)
return result.scalar_one_or_none()
async def add(self, conference: Conference) -> Conference:
"""Добавить конференцию в сессию и сделать flush (нарушение unique — здесь же)."""
self._session.add(conference)
await self._session.flush()
return conference
async def delete(self, conference: Conference) -> None:
"""Удалить конференцию (сеансы/гости удаляются каскадом на уровне БД)."""
await self._session.delete(conference)
await self._session.flush()
async def list_owned(
self, user_id: uuid.UUID, *, email: str, now: datetime
) -> list[Conference]:
"""Конференции владельца ИЛИ приглашённого для «Моих конференций» (решение от
2026-07-20 поверх ADR-003: приглашённый видит конференцию в своих списках) —
закреплённые + предстоящие разовые.
«Приглашённый» — есть строка `conference_invitees` с `user_id == user_id`
ИЛИ с `lower(email) == lower(этого email)` (внешнее приглашение на адрес,
под которым человек впоследствии зарегистрировался). `EXISTS`-подзапрос
не размножает строки `Conference` — `DISTINCT` не требуется.
"""
is_invitee = (
select(ConferenceInvitee.id)
.where(
ConferenceInvitee.conference_id == Conference.id,
or_(
ConferenceInvitee.user_id == user_id,
func.lower(ConferenceInvitee.email) == email.lower(),
),
)
.exists()
)
result = await self._session.execute(
select(Conference)
.where(
or_(Conference.owner_id == user_id, is_invitee),
(Conference.is_pinned.is_(True))
| (
(Conference.scheduled_at.isnot(None))
& (Conference.scheduled_at >= now)
& (Conference.status == "scheduled")
),
)
.order_by(Conference.is_pinned.desc(), Conference.scheduled_at.asc().nulls_last())
)
return list(result.scalars().all())
async def list_calendar_candidates(self, user_id: uuid.UUID, *, email: str) -> list[Conference]:
"""Конференции владельца ИЛИ приглашённого, потенциально дающие вхождения в календаре.
Тот же принцип видимости приглашённого, что и `list_owned` (решение от
2026-07-20). Развёртка диапазона — в сервисном слое
(`services/conferences.py::list_calendar`), здесь только грубая выборка
кандидатов (есть recurrence или указано scheduled_at).
"""
is_invitee = (
select(ConferenceInvitee.id)
.where(
ConferenceInvitee.conference_id == Conference.id,
or_(
ConferenceInvitee.user_id == user_id,
func.lower(ConferenceInvitee.email) == email.lower(),
),
)
.exists()
)
result = await self._session.execute(
select(Conference).where(
or_(Conference.owner_id == user_id, is_invitee),
(Conference.recurrence.isnot(None)) | (Conference.scheduled_at.isnot(None)),
)
)
return list(result.scalars().all())
async def list_expired_unpinned_scheduled(self, *, now: datetime) -> list[Conference]:
"""Незакреплённые плановые конференции без единого сеанса (кандидаты на `ended`).
Точная проверка истечения времени (с учётом `duration_minutes` и
запаса) — в `workers/tasks/maintenance.py`; здесь — грубая выборка по
`NOT EXISTS` сеанса, чтобы не тянуть в память всё лишнее.
"""
has_session = (
select(ConferenceSession.id)
.where(ConferenceSession.conference_id == Conference.id)
.exists()
)
result = await self._session.execute(
select(Conference).where(
Conference.is_pinned.is_(False),
Conference.status == "scheduled",
Conference.scheduled_at.isnot(None),
Conference.scheduled_at < now,
~has_session,
)
)
return list(result.scalars().all())
class ConferenceInviteeRepository:
"""Инкапсулирует SQL-запросы к приглашённым на конференцию (`conference_invitees`, ADR-003)."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_with_user(
self, conference_id: uuid.UUID
) -> list[tuple[ConferenceInvitee, str | None, str | None, str | None]]:
"""Приглашённые конференции + имя/email/путь аватара их пользователя (LEFT JOIN).
Для внешних приглашённых (`user_id IS NULL`) три последних элемента
кортежа — `None` (нет привязанного `User`).
"""
result = await self._session.execute(
select(ConferenceInvitee, User.name_user, User.email, User.avatar_path)
.outerjoin(User, ConferenceInvitee.user_id == User.id)
.where(ConferenceInvitee.conference_id == conference_id)
)
# `User.name_user`/`User.email` типизированы как non-optional (NOT NULL
# в модели) — но при LEFT JOIN без совпадения (внешний приглашённый,
# `user_id IS NULL`) значения реально приходят `NULL`; mypy не видит
# nullability, вносимую `outerjoin`, отсюда явный `cast`.
return cast(
"list[tuple[ConferenceInvitee, str | None, str | None, str | None]]",
list(result.all()),
)
async def existing_user_ids(self, user_ids: set[uuid.UUID]) -> set[uuid.UUID]:
"""Подмножество `user_ids`, реально существующее в `users` (проверка перед вставкой)."""
if not user_ids:
return set()
result = await self._session.execute(select(User.id).where(User.id.in_(user_ids)))
return set(result.scalars().all())
async def exists_for_user(
self, conference_id: uuid.UUID, *, user_id: uuid.UUID, email: str
) -> bool:
"""Приглашён ли `user_id` на конференцию — по `user_id` или по `lower(email)`.
Используется проверкой доступа к детальной карточке (`GET /conferences/{id}`,
решение от 2026-07-20 поверх ADR-003) — приглашённый должен её видеть
наравне с владельцем/администратором.
"""
result = await self._session.execute(
select(ConferenceInvitee.id)
.where(
ConferenceInvitee.conference_id == conference_id,
or_(
ConferenceInvitee.user_id == user_id,
func.lower(ConferenceInvitee.email) == email.lower(),
),
)
.limit(1)
)
return result.scalar_one_or_none() is not None
async def replace_all(
self, conference_id: uuid.UUID, invitees: list[ConferenceInvitee]
) -> None:
"""Полностью заменить состав приглашённых конференции (ADR-003, п.3 — PUT-семантика)."""
await self._session.execute(
delete(ConferenceInvitee).where(ConferenceInvitee.conference_id == conference_id)
)
for invitee in invitees:
self._session.add(invitee)
await self._session.flush()
class ConferenceSessionRepository:
"""Инкапсулирует SQL-запросы к сеансам конференций и их участникам."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_open_by_conference(self, conference_id: uuid.UUID) -> ConferenceSession | None:
"""Вернуть открытый (`t_end IS NULL`) сеанс конференции, если есть."""
result = await self._session.execute(
select(ConferenceSession)
.where(
ConferenceSession.conference_id == conference_id,
ConferenceSession.t_end.is_(None),
)
.order_by(ConferenceSession.t_start.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def create(
self, *, conference_id: uuid.UUID, title: str | None, t_start: datetime
) -> ConferenceSession:
"""Создать новый (открытый) сеанс конференции."""
record = ConferenceSession(conference_id=conference_id, title=title, t_start=t_start)
self._session.add(record)
await self._session.flush()
return record
async def get_or_create_open(
self, *, conference_id: uuid.UUID, title: str | None, t_start: datetime
) -> ConferenceSession:
"""Get-or-create открытого сеанса конференции.
Гвард на случай, если `room_started` было пропущено и первым пришло
`participant_joined`.
"""
existing = await self.get_open_by_conference(conference_id)
if existing is not None:
return existing
return await self.create(conference_id=conference_id, title=title, t_start=t_start)
async def close(self, session_record: ConferenceSession, *, t_end: datetime) -> None:
"""Закрыть сеанс, проставив `t_end`."""
session_record.t_end = t_end
async def list_open(self) -> list[ConferenceSession]:
"""Список всех открытых (`t_end IS NULL`) сеансов.
Используется maintenance-задачей (`workers/tasks/maintenance.py`)
для обхода всех "зависших" сеансов разом.
"""
result = await self._session.execute(
select(ConferenceSession).where(ConferenceSession.t_end.is_(None))
)
return list(result.scalars().all())
async def list_stuck_summarizing(self, *, older_than: datetime) -> list[ConferenceSession]:
"""Сеансы, зависшие на шаге суммаризации: `pipeline_status='summarizing'`,
`summary_data` ещё не заполнен, а сеанс завершился раньше `older_than`.
Кандидаты на повторную постановку `summarize_session` —
`workers/tasks/maintenance.py::recover_stuck_summaries` (уровень 2
защиты от потери постановки задачи при сбое брокера в `run_pipeline`).
"""
result = await self._session.execute(
select(ConferenceSession).where(
ConferenceSession.pipeline_status == "summarizing",
ConferenceSession.summary_data.is_(None),
ConferenceSession.t_end.isnot(None),
ConferenceSession.t_end < older_than,
)
)
return list(result.scalars().all())
async def list_stuck_notifying(self, *, older_than: datetime) -> list[ConferenceSession]:
"""Сеансы, зависшие на шаге уведомления: саммари готово, но `notify_session`
так и не перевела пайплайн в `notified`.
`pipeline_status='summarizing'` + `summary_data IS NOT NULL` +
`t_end < older_than` — кандидаты на повторную постановку
`notify_session` (`workers/tasks/maintenance.py::recover_stuck_notifications`,
уровень 2 защиты от потери постановки задачи при сбое брокера в
`summarize_session`, аналог `list_stuck_summarizing`).
"""
result = await self._session.execute(
select(ConferenceSession).where(
ConferenceSession.pipeline_status == "summarizing",
ConferenceSession.summary_data.isnot(None),
ConferenceSession.t_end.isnot(None),
ConferenceSession.t_end < older_than,
)
)
return list(result.scalars().all())
async def count_by_pipeline_status(self) -> dict[str, int]:
"""Число сеансов в каждом статусе пайплайна (`pipeline_status`).
Используется метриками Prometheus (`backend/api/metrics.py`,
блок 3) для gauge `vidconf_pipeline_sessions{status=...}` — считается
заново при каждом scrape, не кешируется. Статусы без единого сеанса в
результат не попадают (пустая группа), это ожидаемо: вызывающая
сторона сама проставляет 0 для отсутствующих в словаре статусов
(полный перечень — `models.session.PipelineStatus`), чтобы метрика не
"пропадала" из `/metrics` между сборами.
"""
result = await self._session.execute(
select(ConferenceSession.pipeline_status, func.count()).group_by(
ConferenceSession.pipeline_status
)
)
return {status: count for status, count in result.all()}
async def has_active_participants(self, session_id: uuid.UUID) -> bool:
"""Есть ли у сеанса хотя бы один участник без `left_at` (кто-то ещё внутри)."""
result = await self._session.execute(
select(ConferenceParticipant.id)
.where(
ConferenceParticipant.session_id == session_id,
ConferenceParticipant.left_at.is_(None),
)
.limit(1)
)
return result.scalar_one_or_none() is not None
async def add_participant(
self,
*,
session_id: uuid.UUID,
user_id: uuid.UUID | None,
guest_id: uuid.UUID | None,
joined_at: datetime,
) -> ConferenceParticipant:
"""Get-or-create активной (не покинувшей) записи участия — пользователя ИЛИ гостя.
Защищает от дублей при повторной доставке `participant_joined` для
уже присутствующего участника. Ровно один из `user_id`/`guest_id`
должен быть передан (см. CHECK-constraint модели).
"""
stmt = select(ConferenceParticipant).where(
ConferenceParticipant.session_id == session_id,
ConferenceParticipant.left_at.is_(None),
)
stmt = stmt.where(
ConferenceParticipant.user_id == user_id
if user_id is not None
else ConferenceParticipant.guest_id == guest_id
)
existing = (await self._session.execute(stmt)).scalar_one_or_none()
if existing is not None:
return existing
participant = ConferenceParticipant(
session_id=session_id, user_id=user_id, guest_id=guest_id, joined_at=joined_at
)
self._session.add(participant)
await self._session.flush()
return participant
async def get_active_participant(
self,
session_id: uuid.UUID,
*,
user_id: uuid.UUID | None = None,
guest_id: uuid.UUID | None = None,
) -> ConferenceParticipant | None:
"""Найти активную (`left_at IS NULL`) запись присутствия пользователя/гостя в сеансе.
Используется атрибуцией аудиотрека к участнику (`track_published`,
ADR-002): ровно один из `user_id`/`guest_id` должен быть передан.
"""
stmt = select(ConferenceParticipant).where(
ConferenceParticipant.session_id == session_id,
ConferenceParticipant.left_at.is_(None),
)
stmt = stmt.where(
ConferenceParticipant.user_id == user_id
if user_id is not None
else ConferenceParticipant.guest_id == guest_id
)
stmt = stmt.order_by(ConferenceParticipant.joined_at.desc()).limit(1)
return (await self._session.execute(stmt)).scalar_one_or_none()
async def mark_participant_left(
self,
*,
session_id: uuid.UUID,
user_id: uuid.UUID | None,
guest_id: uuid.UUID | None,
left_at: datetime,
) -> None:
"""Проставить `left_at` последней открытой записи участника (пользователя или гостя)."""
stmt = select(ConferenceParticipant).where(
ConferenceParticipant.session_id == session_id,
ConferenceParticipant.left_at.is_(None),
)
stmt = stmt.where(
ConferenceParticipant.user_id == user_id
if user_id is not None
else ConferenceParticipant.guest_id == guest_id
)
stmt = stmt.order_by(ConferenceParticipant.joined_at.desc()).limit(1)
result = await self._session.execute(stmt)
participant = result.scalar_one_or_none()
if participant is not None:
participant.left_at = left_at
async def close_all_open_participants(
self, *, session_id: uuid.UUID, left_at: datetime
) -> None:
"""Закрыть все записи участников сеанса с `left_at IS NULL` (`room_finished`)."""
open_participants = await self._session.scalars(
select(ConferenceParticipant).where(
ConferenceParticipant.session_id == session_id,
ConferenceParticipant.left_at.is_(None),
)
)
for participant in open_participants:
participant.left_at = left_at
class AudioTrackRepository:
"""Инкапсулирует SQL-запросы к записанным аудиотрекам сеансов (`session_audio_tracks`)."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_session_and_track(
self, session_id: uuid.UUID, track_sid: str
) -> SessionAudioTrack | None:
"""Найти строку трека по (`session_id`, `track_sid`) — ключ идемпотентности `create`."""
result = await self._session.execute(
select(SessionAudioTrack).where(
SessionAudioTrack.session_id == session_id,
SessionAudioTrack.track_sid == track_sid,
)
)
return result.scalar_one_or_none()
async def create(
self,
*,
session_id: uuid.UUID,
participant_id: uuid.UUID,
track_sid: str,
egress_id: str | None,
file_path: str | None,
started_at: datetime,
) -> SessionAudioTrack:
"""Идемпотентно создать строку трека по (`session_id`, `track_sid`).
Повторная доставка `track_published` (например, гонка между двумя
одновременными вебхуками) — `INSERT ... ON CONFLICT DO NOTHING` по
уникальному индексу `uq_session_track`, затем возврат уже
существующей строки. `status` по умолчанию `recording`.
"""
insert_stmt = (
pg_insert(SessionAudioTrack)
.values(
session_id=session_id,
participant_id=participant_id,
track_sid=track_sid,
egress_id=egress_id,
file_path=file_path,
started_at=started_at,
)
.on_conflict_do_nothing(constraint="uq_session_track")
.returning(SessionAudioTrack.id)
)
inserted_id = (await self._session.execute(insert_stmt)).scalar_one_or_none()
if inserted_id is None:
existing = await self.get_by_session_and_track(session_id, track_sid)
assert existing is not None # конфликт гарантирует существование строки
return existing
await self._session.flush()
record = await self._session.get(SessionAudioTrack, inserted_id)
assert record is not None
return record
async def finalize(
self,
*,
egress_id: str,
status: str,
ended_at: datetime,
file_path: str | None = None,
) -> SessionAudioTrack | None:
"""Финализировать строку трека по `egress_id` (обработка webhook `egress_ended`).
`status` — `'recorded'` при успехе, `'failed'` при ошибке egress.
Отсутствие строки (например, `track_published` был потерян) — не
ошибка, лог оставляет вызывающая сторона.
"""
result = await self._session.execute(
select(SessionAudioTrack).where(SessionAudioTrack.egress_id == egress_id)
)
record = result.scalar_one_or_none()
if record is None:
return None
record.status = status
record.ended_at = ended_at
if file_path is not None:
record.file_path = file_path
return record
async def list_by_session(self, session_id: uuid.UUID) -> list[SessionAudioTrack]:
"""Список всех треков сеанса (используется оркестрацией пайплайна)."""
result = await self._session.execute(
select(SessionAudioTrack).where(SessionAudioTrack.session_id == session_id)
)
return list(result.scalars().all())

View File

@@ -0,0 +1,59 @@
"""Репозиторий доступа к таблице `users`."""
import uuid
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from models.user import User
class UserRepository:
"""Инкапсулирует SQL-запросы к пользователям."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_email(self, email: str) -> User | None:
"""Найти пользователя по email (регистр значим, как задано в БД)."""
result = await self._session.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def get_by_id(self, user_id: uuid.UUID) -> User | None:
"""Найти пользователя по id."""
return await self._session.get(User, user_id)
async def create(
self,
*,
email: str,
name_user: str,
password_hash: str,
team_id: uuid.UUID | None = None,
) -> User:
"""Создать нового пользователя (role='user', email_verified=False по умолчанию)."""
user = User(email=email, name_user=name_user, password_hash=password_hash, team_id=team_id)
self._session.add(user)
await self._session.flush()
return user
async def list_all(self) -> list[User]:
"""Список всех пользователей (для мультиселекта участников брони, без пагинации)."""
result = await self._session.execute(select(User).order_by(User.name_user))
return list(result.scalars().all())
async def search(self, *, q: str | None, limit: int) -> list[User]:
"""Пикер участников конференции: без `q` — полный список (как `list_all`);
с `q` — поиск по имени/email (`ILIKE`), ограниченный `limit`.
"""
if not q:
return await self.list_all()
like = f"%{q}%"
stmt = (
select(User)
.where(or_(User.name_user.ilike(like), User.email.ilike(like)))
.order_by(User.name_user)
.limit(limit)
)
result = await self._session.execute(stmt)
return list(result.scalars().all())

View File

@@ -0,0 +1 @@
"""Pydantic-схемы (DTO) для входных/выходных данных API."""

158
backend/schemas/admin.py Normal file
View File

@@ -0,0 +1,158 @@
"""Pydantic-схемы админ-API (`/api/v1/admin/*`)."""
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from core.plugins.config import AiLevel, SummaryRecipientsMode
from schemas.conferences import ConferenceOut
from services.ai_levels import AiLevelStatus
class AdminConferenceOut(ConferenceOut):
"""Конференция в ответах админ-API — `ConferenceOut` + данные владельца.
`owner_name`/`owner_email` — `None` для конференции без владельца
(`conferences.owner_id IS NULL`, ADR-001) — колонка «Владелец» в таблице
админки (`frontend/src/api/admin.ts`).
"""
owner_name: str | None = None
owner_email: str | None = None
class AdminConferenceListOut(BaseModel):
"""Страница списка конференций (`GET /admin/conferences`)."""
items: list[AdminConferenceOut]
total: int
class AdminUserOut(BaseModel):
"""Пользователь в ответах админ-API (профиль + служебные поля модерации).
`avatar_url`/`team_name` заполняются явно роутером (не через
`from_attributes` — оба поля вычисляемые, не хранятся как атрибут ORM),
см. `api/admin.py::_to_admin_user_out`.
"""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
name_user: str
role: str
is_blocked: bool
email_verified: bool
created_at: datetime
team_id: uuid.UUID | None = None
avatar_url: str | None = None
team_name: str | None = None
class AdminUserListOut(BaseModel):
"""Страница списка пользователей (`GET /admin/users`)."""
items: list[AdminUserOut]
total: int
class AdminUserCreateIn(BaseModel):
"""Тело создания пользователя администратором (`POST /admin/users`).
Та же политика пароля, что при самостоятельной регистрации
(`RegisterIn.password`, min 8 символов). `team_id` — опциональная
привязка к команде, в отличие от публичной регистрации не завязана на
настройку `registration_team_choice` (админ назначает команду всегда).
"""
name_user: str = Field(min_length=1, max_length=255)
email: EmailStr
password: str = Field(min_length=8)
team_id: uuid.UUID | None = None
class AdminUserUpdateIn(BaseModel):
"""Тело правки пользователя администратором — роль, блокировка, ФИО и/или команда.
`team_id` не различает «поле не передано» и «явный `null`» через
сравнение с `None` — роутер читает `model_fields_set`, чтобы явный сброс
команды (`{"team_id": null}`) отличался от отсутствия поля в запросе
(тот же паттерн, что `summary_recipients` в `schemas/conferences.py`).
`name_user` — та же правка, что доступна пользователю в своём профиле
(редактирование по тем же правилам).
"""
role: Literal["admin", "user"] | None = None
is_blocked: bool | None = None
team_id: uuid.UUID | None = None
name_user: str | None = Field(default=None, min_length=1, max_length=255)
class TeamOut(BaseModel):
"""Команда в ответах админ-API."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
created_at: datetime
class TeamListOut(BaseModel):
"""Список команд (`GET /admin/teams`), отсортирован по названию."""
items: list[TeamOut]
total: int
class TeamCreateIn(BaseModel):
"""Тело создания команды; `name` обрезается от пробелов до проверки длины."""
model_config = ConfigDict(str_strip_whitespace=True)
name: str = Field(min_length=1, max_length=255)
class TeamUpdateIn(BaseModel):
"""Тело переименования команды; `name` обрезается от пробелов до проверки длины."""
model_config = ConfigDict(str_strip_whitespace=True)
name: str = Field(min_length=1, max_length=255)
class InvitationsSendIn(BaseModel):
"""Тело ручной рассылки .ics-приглашений (`POST /admin/conferences/{id}/invitations`).
Пустой список получателей не отличается от отсутствующего поля — оба
трактуются как «получатели по умолчанию» (см. `workers/tasks/invitations.py`).
"""
emails: list[EmailStr] | None = None
class SettingsOut(BaseModel):
"""Эффективные настройки инстанса для отображения в админке.
`transcription_queue_served` — обслуживается ли очередь `transcription`
хотя бы одним воркером Celery
прямо сейчас (`services.pipeline_producer.transcription_queue_served`);
`False` при включённой транскрибации — сигнал админке показать
предупреждение рядом с чекбоксом AI («модуль включён, но задачи некому
обрабатывать»), не связано с доступностью уровня AI (`ai_levels`,
которая смотрит только на железо/скачанные модели).
"""
chat_enabled: bool
transcription_enabled: bool
ai_level: AiLevel
ai_levels: list[AiLevelStatus]
transcription_queue_served: bool
summary_recipients: SummaryRecipientsMode
display_timezone: str
registration_team_choice: bool
registration_email_domain_enabled: bool
registration_email_domain: str | None = None

113
backend/schemas/auth.py Normal file
View File

@@ -0,0 +1,113 @@
"""Pydantic-схемы для аутентификации и профиля пользователя."""
import uuid
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class RegisterIn(BaseModel):
"""Тело запроса регистрации нового пользователя.
`team_id` допустим только при включённой настройке инстанса
`registration_team_choice` (см. `GET /auth/registration-options`) и
существующей команде — иначе `POST /auth/register` вернёт 400.
"""
email: EmailStr
name_user: str = Field(min_length=1, max_length=255)
password: str = Field(min_length=8)
team_id: uuid.UUID | None = None
class VerifyEmailIn(BaseModel):
"""Тело запроса подтверждения email."""
token: str = Field(min_length=1)
class TokenOut(BaseModel):
"""Ответ с access-токеном; refresh-токен передаётся отдельно в httpOnly cookie."""
access_token: str
token_type: str = "bearer"
class UserOut(BaseModel):
"""Публичное представление пользователя (профиль текущего пользователя).
`email: str`, а не `EmailStr` — намеренно: это response-схема,
отражающая уже сохранённые в БД данные, а не принимающая новый ввод.
`EmailStr` дополнительно отсекает синтаксически валидные, но
зарезервированные домены (`.local`, `.test` и т.п. из RFC 6761) — валидный
email на входе (`RegisterIn`, ниже) мог быть заведён напрямую в БД (seed,
ручная миграция) с таким доменом; строгая `EmailStr` на выходе привела бы
к 500 `ResponseValidationError` для уже существующих пользователей.
"""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
name_user: str
role: str
class UserListItemOut(BaseModel):
"""Элемент списка пользователей (пикер участников конференции/мультиселект брони)."""
id: uuid.UUID
display_name: str
avatar_url: str | None = None
class ProfileUpdateIn(BaseModel):
"""Тело правки профиля текущего пользователя.
Email НЕ принимается — read-only поле профиля. `team_id` не различает
«поле не передано» и «явный `null`» через сравнение с `None` — роутер
читает `model_fields_set` (тот же приём, что `AdminUserUpdateIn.team_id`).
"""
name_user: str | None = Field(default=None, min_length=1, max_length=255)
team_id: uuid.UUID | None = None
class UserProfileOut(UserOut):
"""Профиль пользователя (свой либо открытый администратором) — `UserOut` + аватар/команда."""
avatar_url: str | None = None
team_id: uuid.UUID | None = None
team_name: str | None = None
class PasswordChangeIn(BaseModel):
"""Тело смены пароля текущим пользователем (`POST /users/me/password`).
Политика сложности `new_password` — та же, что при регистрации
(`RegisterIn.password`, min 8 символов).
"""
current_password: str
new_password: str = Field(min_length=8)
class RegistrationTeamOptionOut(BaseModel):
"""Команда в списке опций публичной карточки регистрации."""
id: uuid.UUID
name: str
class RegistrationOptionsOut(BaseModel):
"""Публичные опции формы регистрации (`GET /auth/registration-options`).
`teams` отдаётся только при `team_choice_enabled=True` — иначе пустой
список (справочник команд не раскрывается, пока выбор выключен).
`email_domain` — эталонный домен при включённой верификации регистрации
по домену email (настройка инстанса `registration_email_domain`), иначе
`None`.
"""
team_choice_enabled: bool
teams: list[RegistrationTeamOptionOut]
email_domain: str | None = None

83
backend/schemas/chat.py Normal file
View File

@@ -0,0 +1,83 @@
"""Pydantic-схемы протокола WS-чата конференции (`api/chat.py`).
Входящий протокол — дискриминированное объединение по полю `type`: первым
сообщением клиент обязан прислать `auth` (LiveKit access-токен, не
query-параметр — не палим токен в логах nginx), далее — произвольное число
`message`. Исходящий протокол — `history` (один раз, сразу после успешной
аутентификации), `message` (broadcast через Redis pub/sub) и `error`.
"""
from datetime import UTC, datetime
from typing import Annotated, Any, Literal
from pydantic import BaseModel, Field, TypeAdapter, field_serializer, field_validator
# Ограничение длины текста сообщения.
MAX_MESSAGE_LENGTH = 2000
def _to_iso_z(value: datetime) -> str:
"""Отформатировать aware-datetime как UTC ISO-строку с суффиксом `Z`."""
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
class ChatAuthIn(BaseModel):
"""Первое сообщение клиента — аутентификация LiveKit access-токеном."""
type: Literal["auth"]
token: str
class ChatMessageIn(BaseModel):
"""Сообщение клиента с текстом чата — text обрезается по пробелам и не должен быть пустым."""
type: Literal["message"]
text: str = Field(min_length=1, max_length=MAX_MESSAGE_LENGTH)
@field_validator("text", mode="before")
@classmethod
def _strip(cls, value: Any) -> Any:
return value.strip() if isinstance(value, str) else value
# Дискриминированное объединение входящих сообщений клиента по полю `type`.
ChatClientEnvelope = Annotated[ChatAuthIn | ChatMessageIn, Field(discriminator="type")]
chat_client_envelope_adapter: TypeAdapter[ChatAuthIn | ChatMessageIn] = TypeAdapter(
ChatClientEnvelope
)
class ChatMessageOut(BaseModel):
"""Одно сообщение чата в исходящем протоколе (history/broadcast)."""
id: int
author_id: str | None
author_name: str
is_guest: bool
text: str
created_at: datetime
@field_serializer("created_at")
def _serialize_created_at(self, value: datetime) -> str:
return _to_iso_z(value)
class ChatHistoryOut(BaseModel):
"""История последних сообщений открытой сессии — отправляется один раз после auth."""
type: Literal["history"] = "history"
messages: list[ChatMessageOut]
class ChatMessageEventOut(BaseModel):
"""Одно новое сообщение чата — broadcast через Redis pub/sub (в т.ч. отправителю)."""
type: Literal["message"] = "message"
message: ChatMessageOut
class ChatErrorOut(BaseModel):
"""Сообщение об ошибке протокола (например, невалидный текст) без разрыва соединения."""
type: Literal["error"] = "error"
code: str

View File

@@ -0,0 +1,212 @@
"""Pydantic-схемы для конференций (`/api/v1/conferences`) и их join-потока (ADR-001)."""
import uuid
from datetime import UTC, datetime, timedelta
from pydantic import BaseModel, EmailStr, Field, field_serializer, field_validator, model_validator
from core.plugins.config import SummaryRecipientsMode
from services.recurrence import RecurrenceRule
# Допуск в прошлое при плановом создании/правке — небольшой запас на задержку
# сети/рассинхронизацию часов клиента (перенесено из старых `schemas/bookings.py`).
PAST_TOLERANCE = timedelta(minutes=1)
def _to_iso_z(value: datetime) -> str:
"""Отформатировать aware-datetime как UTC ISO-строку с суффиксом `Z`."""
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
def _require_aware_utc(value: datetime) -> datetime:
"""Требовать явную таймзону и привести значение к UTC (в БД и API — только UTC)."""
if value.tzinfo is None:
raise ValueError("datetime_must_be_timezone_aware")
return value.astimezone(UTC)
class InviteeIn(BaseModel):
"""Один приглашённый участник в теле создания/правки конференции (ADR-003).
Ровно одно из `user_id`/`email` — зарегистрированный пользователь ИЛИ
внешний адрес; email нормализуется в lower-case (совпадает с хранением в
`conference_invitees.email`).
"""
user_id: uuid.UUID | None = None
email: EmailStr | None = None
@field_validator("email")
@classmethod
def _normalize_email(cls, value: str | None) -> str | None:
return value.lower() if value is not None else None
@model_validator(mode="after")
def _validate(self) -> "InviteeIn":
if (self.user_id is None) == (self.email is None):
raise ValueError("invitee_requires_exactly_one_identity")
return self
class InviteeOut(BaseModel):
"""Приглашённый в ответе API. Организатор — всегда первый элемент `participants`."""
user_id: uuid.UUID | None = None
email: str | None = None
name: str | None = None
avatar_url: str | None = None
is_organizer: bool = False
class ConferenceCreateIn(BaseModel):
"""Тело запроса создания конференции.
Без `scheduled_at` — мгновенная конференция (создатель входит сразу же,
ответ содержит `join`); с `scheduled_at` — плановая (`status=scheduled`).
"""
title: str | None = Field(default=None, max_length=255)
scheduled_at: datetime | None = None
duration_minutes: int | None = Field(default=None, gt=0)
is_pinned: bool = False
recurrence: RecurrenceRule | None = None
is_closed: bool = False
password: str | None = Field(default=None, min_length=4)
# Переопределение рассылки саммари: `None` — дефолт
# инстанса (`instance_settings['summary_recipients']`).
summary_recipients: SummaryRecipientsMode | None = None
# Состав приглашённых (ADR-003); `None` — без участников (кроме
# организатора, который добавляется автоматически и неудаляемо).
participants: list[InviteeIn] | None = None
@field_validator("scheduled_at")
@classmethod
def _normalize_scheduled_at(cls, value: datetime | None) -> datetime | None:
return _require_aware_utc(value) if value is not None else None
@model_validator(mode="after")
def _validate(self) -> "ConferenceCreateIn":
if self.is_closed and not self.password:
raise ValueError("closed_conference_requires_password")
if self.recurrence is not None and not self.is_pinned:
raise ValueError("recurrence_requires_pinned")
if self.scheduled_at is not None and self.scheduled_at < datetime.now(UTC) - PAST_TOLERANCE:
raise ValueError("scheduled_at_in_the_past")
return self
class ConferenceUpdateIn(BaseModel):
"""Тело запроса частичной правки конференции — все поля опциональны."""
title: str | None = Field(default=None, max_length=255)
scheduled_at: datetime | None = None
duration_minutes: int | None = Field(default=None, gt=0)
is_pinned: bool | None = None
recurrence: RecurrenceRule | None = None
is_closed: bool | None = None
password: str | None = Field(default=None, min_length=4)
# `None` не различает «не передано» и «явный сброс на дефолт инстанса» —
# сервис читает `model_fields_set` (тот же паттерн, что у `recurrence`).
summary_recipients: SummaryRecipientsMode | None = None
# `None` — не менять состав; список — полная замена (diff считает backend,
# ADR-003, п.3). Организатор неудаляем и в списке не нужен — молча
# дедуплицируется, если всё же передан.
participants: list[InviteeIn] | None = None
@field_validator("scheduled_at")
@classmethod
def _normalize_scheduled_at(cls, value: datetime | None) -> datetime | None:
return _require_aware_utc(value) if value is not None else None
class JoinOut(BaseModel):
"""Данные, необходимые клиенту для подключения к LiveKit-комнате конференции."""
livekit_url: str
token: str
room_name: str
conference_id: uuid.UUID
# Тоггл инстанса `chat.enabled` на момент входа — клиент решает,
# показывать ли UI чата, не дожидаясь ошибки WS-подключения.
chat_enabled: bool
class ConferenceOut(BaseModel):
"""Конференция в ответе API.
`participants` заполняется только в детальных ответах (создание, правка,
`GET /conferences/{id}`) — списочные эндпоинты (`/my`, `/calendar`) состав
не раздувают и оставляют его пустым (ADR-003, п.5).
"""
id: uuid.UUID
number: str
slug: str
title: str | None
status: str
is_pinned: bool
is_closed: bool
scheduled_at: datetime | None
duration_minutes: int | None
recurrence: RecurrenceRule | None
next_occurrence: datetime | None = None
created_at: datetime
join: JoinOut | None = None
# `None` = используется дефолт инстанса (`instance_settings['summary_recipients']`).
summary_recipients: SummaryRecipientsMode | None = None
owner_id: uuid.UUID | None = None
# Относительно ТЕКУЩЕГО пользователя запроса (не обязательно владелец).
is_owner: bool = False
organizer_name: str | None = None
participants: list[InviteeOut] = Field(default_factory=list)
@field_serializer("scheduled_at", "created_at", "next_occurrence")
def _serialize_utc_z(self, value: datetime | None) -> str | None:
return _to_iso_z(value) if value is not None else None
class OccurrenceOut(BaseModel):
"""Одно вхождение конференции (закреплённой с повторением или разовой) в календаре."""
conference_id: uuid.UUID
title: str | None
starts_at: datetime
ends_at: datetime
number: str
slug: str
is_pinned: bool
is_closed: bool
@field_serializer("starts_at", "ends_at")
def _serialize_utc_z(self, value: datetime) -> str:
return _to_iso_z(value)
class ResolveOut(BaseModel):
"""Публичное представление конференции по номеру/ссылке (экран входа, без auth).
Для завершённой (`status=ended`) конференции `is_closed`/`requires_password`
намеренно не заполняются (`None`) — вход всё равно невозможен (410 у
`join`/`guest-join`), а признак закрытости уже неактуален (ADR-001, п.4,
уточнение резолва).
"""
id: uuid.UUID
title: str | None
status: str
is_closed: bool | None = None
requires_password: bool | None = None
class JoinIn(BaseModel):
"""Тело запроса входа зарегистрированного пользователя — пароль закрытой конференции."""
password: str | None = None
class GuestJoinIn(BaseModel):
"""Тело запроса гостевого входа: представиться (имя обязательно, email — факультативно)."""
display_name: str = Field(min_length=1, max_length=255)
email: EmailStr | None = None
password: str | None = None

View File

View File

@@ -0,0 +1,120 @@
"""Принудительное применение матрицы «пресет → настройки» на живой инсталляции.
Бутстрап (`main.py::lifespan`,
`InstanceSettingsService.ensure_bootstrapped`) применяет overrides пресета
инсталлятора (`BOOTSTRAP_CHAT_ENABLED`/`BOOTSTRAP_TRANSCRIPTION_ENABLED`/
`BOOTSTRAP_AI_LEVEL` в `.env`, см. `core.config.Settings`) только на чистой
БД (`INSERT ... ON CONFLICT DO NOTHING`) — строки уже существующей
инсталляции он не трогает. Этот скрипт запускает install.sh при ПОВТОРНОМ
запуске с другим пресетом на живой инсталляции, после подтверждения в
опроснике («обновить настройки модулей под пресет?») либо флага `--yes`
самого install.sh.
Управляет РОВНО 4 ключами (`chat`, `transcriber`, `summarizer`, `ai_level`,
см. `services.instance_settings.BOOTSTRAP_MANAGED_KEYS`) — остальные
настройки инстанса (таймзона, рассылка саммари, регистрация) этот скрипт не
трогает. Без `--force` пишет только ОТСУТСТВУЮЩИЕ из них — страховка от
молчаливой потери ручных правок администратора; с `--force` перезаписывает
все 4 значениями текущего пресета (`transcriber`/`summarizer` — целиком, тем
же дефолтом `plugins.yaml` + `enabled`, что и обычный бутстрап: подмена
provider/model для уровней `medium`/`max` происходит при чтении конфигурации,
см. `services.instance_settings.load_effective_config`, а не при записи).
`ai_level` пишется напрямую в БД, минуя валидацию доступности
(`services.ai_levels.detect_ai_levels`): модели уровня `medium`/`max`
докачиваются отдельными профилями `docker-compose` уже ПОСЛЕ первого запуска
install.sh, и жёсткая валидация здесь давала бы ложный отказ применения
пресета, пока докачка не завершена — вместо этого пишем предупреждение в лог.
Запустить: `uv run python -m scripts.apply_preset_settings [--force]`
"""
import argparse
import asyncio
import logging
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from core.config import get_settings
from core.db import async_session_maker, engine
from core.plugins.config import load_plugins_config
from models.instance_setting import InstanceSetting
from services.instance_settings import (
BOOTSTRAP_MANAGED_KEYS,
bootstrap_overrides_from_settings,
build_bootstrap_defaults,
)
logger = logging.getLogger(__name__)
async def apply_preset_settings(*, force: bool) -> list[str]:
"""Применить текущий пресет (`BOOTSTRAP_*`) к управляемым ключам `instance_settings`.
Возвращает список фактически записанных ключей (пустой — писать было
нечего: все 4 ключа уже существуют и `force=False`).
"""
settings = get_settings()
plugins = load_plugins_config(settings.plugins_config_path)
overrides = bootstrap_overrides_from_settings(settings)
defaults = build_bootstrap_defaults(plugins, overrides)
async with async_session_maker() as session:
result = await session.execute(
select(InstanceSetting.key).where(InstanceSetting.key.in_(BOOTSTRAP_MANAGED_KEYS))
)
existing_keys = set(result.scalars().all())
written: list[str] = []
for key in BOOTSTRAP_MANAGED_KEYS:
if key in existing_keys and not force:
continue
value = defaults[key]
stmt = (
pg_insert(InstanceSetting)
.values(key=key, value=value)
.on_conflict_do_update(index_elements=["key"], set_={"value": value})
)
await session.execute(stmt)
written.append(key)
await session.commit()
if "ai_level" in written:
logger.warning(
"apply_preset_settings: ai_level=%r записан напрямую, без проверки доступности "
"(detect_ai_levels) — модели уровня могут ещё докачиваться профилем docker-compose",
defaults["ai_level"]["level"],
)
logger.info(
"apply_preset_settings: обновлены ключи %s (force=%s, пропущены как уже существующие: %s)",
written or "нет",
force,
sorted(existing_keys - set(written)) or "нет",
)
return written
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Применить матрицу «пресет → настройки» инсталлятора к instance_settings."
)
parser.add_argument(
"--force",
action="store_true",
help="перезаписать уже существующие ключи (без флага пишутся только отсутствующие)",
)
return parser.parse_args()
async def _main() -> None:
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
)
args = _parse_args()
await apply_preset_settings(force=args.force)
await engine.dispose()
if __name__ == "__main__":
asyncio.run(_main())

51
backend/scripts/seed.py Normal file
View File

@@ -0,0 +1,51 @@
"""Идемпотентный seed скрипт: единственный админ-пользователь.
Комнаты (100 демо-комнат) больше не существуют как сущность (ADR-001) —
seed сокращён до создания администратора.
Запустить с: `uv run python -m scripts.seed`
"""
import asyncio
from argon2 import PasswordHasher
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from core.db import async_session_maker, engine
from models.user import User
_hasher = PasswordHasher()
async def _seed_admin(session: AsyncSession) -> None:
settings = get_settings()
email = settings.seed_admin_email
password = settings.seed_admin_password
existing = await session.scalar(select(User).where(User.email == email))
if existing is not None:
return
session.add(
User(
email=email,
name_user="Admin",
password_hash=_hasher.hash(password),
role="admin",
email_verified=True,
)
)
async def seed() -> None:
"""Upsert админ-пользователя; безопасно запускать несколько раз."""
async with async_session_maker() as session:
await _seed_admin(session)
await session.commit()
await engine.dispose()
if __name__ == "__main__":
asyncio.run(seed())

View File

View File

@@ -0,0 +1,98 @@
"""Определение доступности уровней AI-модуля (`min`/`medium`/`max`) инстанса.
Матрица уровней (модели, требования RAM/GPU/VRAM, пути моделей на дисковых
томах) — константа `TIERS` (`services/ai_tiers.py`), единственный источник
истины — ADR-004 (`docs/architecture/adr/004-ai-tier-matrix.md`). Детект
читает обнаруженное `install.sh` железо (`HW_*` в `.env`, `core.config.Settings`)
и факт наличия файлов моделей на дисковых томах — без зависимости от
torch/nvidia-smi внутри процесса backend/воркеров (переменные пишет установщик,
а не рантайм-детект GPU).
"""
from pathlib import Path
from pydantic import BaseModel
from core.config import Settings, get_settings
from core.plugins.config import AiLevel, InstanceConfig
from services.ai_tiers import TIERS, WHISPER_MODELS_ROOT, TierSpec
_PRESET_BY_LEVEL: dict[AiLevel, int] = {"min": 3, "medium": 4, "max": 5}
"""Номер пресета инсталлятора, соответствующего уровню (ADR-004, таблица
требований железа) — используется в тексте причины недоступности."""
class AiLevelStatus(BaseModel):
"""Доступность одного уровня AI с человекочитаемой причиной отказа."""
level: AiLevel
available: bool
reason: str | None = None
def detect_ai_levels(cfg: InstanceConfig) -> list[AiLevelStatus]:
"""Вернуть статусы всех уровней AI по обнаруженному железу и скачанным моделям.
`cfg` пока не влияет на результат (доступность уровня зависит только от
железа и файлов моделей на диске, не от текущих настроек инстанса), но
остаётся параметром сигнатуры — используется и `api/admin.py`, и
`services/instance_settings.py::update`, где эффективная конфигурация уже
под рукой.
"""
settings = get_settings()
statuses: list[AiLevelStatus] = []
for level in ("min", "medium", "max"):
reasons = _unavailability_reasons(level, TIERS[level], settings)
statuses.append(
AiLevelStatus(level=level, available=not reasons, reason="; ".join(reasons) or None)
)
return statuses
def _unavailability_reasons(level: AiLevel, spec: TierSpec, settings: Settings) -> list[str]:
"""Собрать причины недоступности уровня `level` (пустой список — уровень доступен)."""
reasons: list[str] = []
if settings.hw_ram_mb is not None and settings.hw_ram_mb < spec.min_ram_mb:
reasons.append(f"недостаточно RAM: нужно {spec.min_ram_mb // 1024} ГБ")
if spec.requires_gpu:
required_vram_gb = (spec.min_vram_mb or 0) // 1024
if not settings.hw_gpu_name:
reasons.append(f"требуется GPU NVIDIA ≥{required_vram_gb} ГБ VRAM, не обнаружен")
elif spec.min_vram_mb is not None and (settings.hw_vram_mb or 0) < spec.min_vram_mb:
found_vram_gb = (settings.hw_vram_mb or 0) // 1024
reasons.append(
f"требуется GPU NVIDIA ≥{required_vram_gb} ГБ VRAM, "
f"обнаружено только {found_vram_gb} ГБ"
)
preset = _PRESET_BY_LEVEL[level]
for path in spec.model_files:
if not _model_downloaded(path):
reasons.append(
f"{_describe_model_file(path)} не скачана — запустите install.sh "
f"с пресетом {preset}"
)
return reasons
def _model_downloaded(path: str) -> bool:
"""Проверить, скачана ли модель по пути на томе.
Файл — непустой; каталог (например, кэш huggingface_hub с хэшированными
поддиректориями снапшотов) — непустой каталог, без проверки конкретных
вложенных файлов.
"""
p = Path(path)
if p.is_dir():
return any(p.iterdir())
return p.is_file() and p.stat().st_size > 0
def _describe_model_file(path: str) -> str:
"""Человекочитаемое имя модели для причины недоступности («модель транскрибации small»)."""
name = Path(path).name
kind = "транскрибации" if path.startswith(f"{WHISPER_MODELS_ROOT}/") else "суммаризации"
return f"модель {kind} {name}"

View File

@@ -0,0 +1,137 @@
"""Константная матрица уровней AI (`min`/`medium`/`max`) — ADR-004.
Единственный источник моделей/квантов/параметров генерации/требований
железа — `docs/architecture/adr/004-ai-tier-matrix.md`; этот модуль переводит
матрицу ADR в структуры, которыми пользуются `services/ai_levels.py` (детект
доступности уровня) и `services/instance_settings.py::load_effective_config`
(подмена `transcriber`/`summarizer` эффективной конфигурации для `medium`/`max` —
`min` продолжает использовать дефолты `plugins.yaml`/правки администратора).
Пути моделей на дисковых томах (`model_files`, `options.download_root`,
`options.tokenizer_path`) — контракт с инсталлятором
(`deploy/llm/download-model.sh` и его аналог для faster-whisper): скрипты
обязаны скачивать модели именно по этим путям, иначе детект доступности
уровня будет ошибочно считать модель нескачанной.
"""
from pydantic import BaseModel
from core.plugins.config import AiLevel, SummarizerConfig, TranscriberConfig
WHISPER_MODELS_ROOT = "/models/whisper"
"""Корень тома с моделями faster-whisper; каждый уровень хранит свою модель в
одноимённом (модели, не уровню) подкаталоге — общий volume `whisper-cache`."""
QWEN_MODELS_ROOT = "/models/qwen"
"""Корень тома с GGUF-квантами и токенизаторами Qwen — общий volume `llm-models`."""
_LLM_BASE_URL_CPU = "http://llm:8080/v1"
"""CPU-сервер llama.cpp (compose-сервис `llm`) — уровни `min`/`medium`."""
_LLM_BASE_URL_GPU = "http://llm-gpu:8080/v1"
"""GPU-сервер llama.cpp (compose-сервис `llm-gpu`, образ `...:server-cuda`)
— уровень `max` (`requires_gpu=True`, GPU обязателен)."""
class TierSpec(BaseModel):
"""Полная конфигурация одного уровня AI: плагины, требования железа, модели."""
transcriber: TranscriberConfig
summarizer: SummarizerConfig
min_ram_mb: int
requires_gpu: bool
min_vram_mb: int | None = None
model_files: list[str]
TIERS: dict[AiLevel, TierSpec] = {
"min": TierSpec(
transcriber=TranscriberConfig(
provider="faster_whisper_cpu",
model="small",
options={"download_root": f"{WHISPER_MODELS_ROOT}/small"},
),
summarizer=SummarizerConfig(
provider="qwen_local",
model="qwen3.5-4b-instruct-q4_k_m",
chunk_minutes=20,
options={
"base_url": _LLM_BASE_URL_CPU,
"tokenizer_path": f"{QWEN_MODELS_ROOT}/qwen3.5-4b-instruct.tokenizer.json",
"temperature": 0.2,
"max_tokens_map": 1024,
"max_tokens_reduce": 1536,
},
),
min_ram_mb=16 * 1024,
requires_gpu=False,
min_vram_mb=None,
model_files=[
f"{WHISPER_MODELS_ROOT}/small",
f"{QWEN_MODELS_ROOT}/qwen3.5-4b-instruct-q4_k_m.gguf",
f"{QWEN_MODELS_ROOT}/qwen3.5-4b-instruct.tokenizer.json",
],
),
"medium": TierSpec(
transcriber=TranscriberConfig(
provider="faster_whisper_cpu",
model="medium",
options={"download_root": f"{WHISPER_MODELS_ROOT}/medium"},
),
summarizer=SummarizerConfig(
provider="qwen_local",
model="qwen3.5-9b-instruct-q4_k_m",
chunk_minutes=20,
options={
"base_url": _LLM_BASE_URL_CPU,
"tokenizer_path": f"{QWEN_MODELS_ROOT}/qwen3.5-9b-instruct.tokenizer.json",
"temperature": 0.2,
"max_tokens_map": 1024,
"max_tokens_reduce": 2048,
},
),
min_ram_mb=32 * 1024,
# GPU опционален на этом уровне (ADR-004: полный offload от ~8 ГБ
# VRAM ускоряет, но не требуется) — константная матрица держит
# безопасный CPU-вариант `faster_whisper_cpu`/CPU llama.cpp;
# GPU-ускорение уровня `medium` — ручная настройка администратора
# поверх этой матрицы (вне детекта доступности).
requires_gpu=False,
min_vram_mb=8 * 1024,
model_files=[
f"{WHISPER_MODELS_ROOT}/medium",
f"{QWEN_MODELS_ROOT}/qwen3.5-9b-instruct-q4_k_m.gguf",
f"{QWEN_MODELS_ROOT}/qwen3.5-9b-instruct.tokenizer.json",
],
),
"max": TierSpec(
transcriber=TranscriberConfig(
provider="faster_whisper_gpu",
model="large-v3",
options={
"download_root": f"{WHISPER_MODELS_ROOT}/large-v3",
"compute_type": "float16",
},
),
summarizer=SummarizerConfig(
provider="qwen_local",
model="qwen3.5-35b-a3b-instruct-q4_k_m",
chunk_minutes=20,
options={
"base_url": _LLM_BASE_URL_GPU,
"tokenizer_path": f"{QWEN_MODELS_ROOT}/qwen3.5-35b-a3b-instruct.tokenizer.json",
"temperature": 0.2,
"max_tokens_map": 1536,
"max_tokens_reduce": 2560,
},
),
min_ram_mb=64 * 1024,
requires_gpu=True,
min_vram_mb=16 * 1024,
model_files=[
f"{WHISPER_MODELS_ROOT}/large-v3",
f"{QWEN_MODELS_ROOT}/qwen3.5-35b-a3b-instruct-q4_k_m.gguf",
f"{QWEN_MODELS_ROOT}/qwen3.5-35b-a3b-instruct.tokenizer.json",
],
),
}

239
backend/services/auth.py Normal file
View File

@@ -0,0 +1,239 @@
"""Бизнес-логика аутентификации: регистрация, подтверждение email, JWT access/refresh.
Refresh-токены хранятся server-side в Redis (`refresh:{jti}` -> user_id) с
TTL, равным сроку жизни refresh-токена. Каждое успешное использование
refresh-токена ротирует его: старый `jti` немедленно удаляется, выдаётся
новый; повторное использование уже потраченного refresh-токена (reuse)
обнаруживается по отсутствию ключа в Redis.
"""
import hashlib
import secrets
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import jwt
from redis.asyncio import Redis
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from core.security import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
)
from models.email_verification import EmailVerificationToken
from models.user import User
from repositories.admin import TeamRepository
from repositories.users import UserRepository
from services.email import EmailBackend
from services.instance_settings import InstanceSettingsService
REFRESH_KEY_PREFIX = "refresh:"
class EmailAlreadyRegisteredError(Exception):
"""Пользователь с таким email уже зарегистрирован."""
class InvalidTeamSelectionError(Exception):
"""Выбор команды при регистрации недоступен или команда не существует.
Публичный эндпоинт `/auth/register` не должен различать эти две причины
в ответе (не раскрываем администраторскую настройку/список команд
перебором id) — единая ошибка для обоих случаев.
"""
class InvalidEmailDomainError(Exception):
"""Домен email регистрирующегося не совпадает с эталонным доменом инстанса.
Поднимается только при включённой настройке инстанса
`registration_email_domain_enabled` (см. `InstanceSettingsService`).
"""
class InvalidVerificationTokenError(Exception):
"""Токен подтверждения email не найден, просрочен или уже использован."""
class InvalidCredentialsError(Exception):
"""Неверный email или пароль."""
class EmailNotVerifiedError(Exception):
"""Email пользователя ещё не подтверждён."""
class InvalidRefreshTokenError(Exception):
"""Refresh-токен невалиден, просрочен, отозван или уже был использован (reuse)."""
@dataclass(frozen=True, slots=True)
class TokenPair:
"""Пара выданных JWT-токенов (access — в теле ответа, refresh — в cookie)."""
access_token: str
refresh_token: str
class AuthService:
"""Инкапсулирует сценарии регистрации, входа, обновления и отзыва токенов."""
def __init__(self, session: AsyncSession, redis: Redis, email_backend: EmailBackend) -> None:
self._session = session
self._redis = redis
self._email_backend = email_backend
self._users = UserRepository(session)
self._settings = get_settings()
async def register(
self,
*,
email: str,
name_user: str,
password: str,
team_id: uuid.UUID | None = None,
) -> User:
"""Зарегистрировать пользователя и отправить письмо для подтверждения email.
`team_id` допустим, только если в настройках инстанса включён выбор
команды при регистрации (`registration_team_choice`) и команда
существует — иначе `InvalidTeamSelectionError` (публичный
эндпоинт, деталей не раскрываем). Если включена верификация домена
email (`registration_email_domain_enabled`), домен `email` (часть
после `@`, без учёта регистра) должен совпадать с эталонным —
иначе `InvalidEmailDomainError`. Обе проверки — до создания
пользователя.
"""
existing = await self._users.get_by_email(email)
if existing is not None:
raise EmailAlreadyRegisteredError(email)
cfg = await InstanceSettingsService(self._session).get()
if cfg.registration_email_domain_enabled:
email_domain = email.rsplit("@", 1)[-1].lower()
if email_domain != cfg.registration_email_domain:
raise InvalidEmailDomainError(email)
if team_id is not None:
if not cfg.registration_team_choice:
raise InvalidTeamSelectionError(team_id)
team = await TeamRepository(self._session).get(team_id)
if team is None:
raise InvalidTeamSelectionError(team_id)
user = await self._users.create(
email=email,
name_user=name_user,
password_hash=hash_password(password),
team_id=team_id,
)
await self._issue_verification_email(user)
await self._session.commit()
return user
async def verify_email(self, token: str) -> None:
"""Подтвердить email пользователя по токену из письма."""
token_hash = _hash_token(token)
result = await self._session.execute(
select(EmailVerificationToken).where(EmailVerificationToken.token_hash == token_hash)
)
record = result.scalar_one_or_none()
now = datetime.now(UTC)
if record is None or record.used_at is not None or record.expires_at < now:
raise InvalidVerificationTokenError
user = await self._users.get_by_id(record.user_id)
if user is None:
raise InvalidVerificationTokenError
record.used_at = now
user.email_verified = True
await self._session.commit()
async def login(self, *, email: str, password: str) -> TokenPair:
"""Проверить учётные данные и выдать пару access/refresh токенов."""
user = await self._users.get_by_email(email)
if user is None or not verify_password(password, user.password_hash):
raise InvalidCredentialsError
if not user.email_verified:
raise EmailNotVerifiedError
return await self._issue_token_pair(user.id, user.role)
async def refresh(self, refresh_token: str) -> TokenPair:
"""Провалидировать refresh-токен, ротировать его и выдать новую пару токенов."""
user_id = await self._validate_and_consume(refresh_token)
user = await self._users.get_by_id(user_id)
if user is None:
raise InvalidRefreshTokenError
return await self._issue_token_pair(user.id, user.role)
async def logout(self, refresh_token: str) -> None:
"""Отозвать refresh-токен (удалить его из Redis), если он вообще декодируется."""
try:
payload = decode_token(refresh_token)
except jwt.PyJWTError:
return
jti = payload.get("jti")
if jti:
await self._redis.delete(f"{REFRESH_KEY_PREFIX}{jti}")
async def _validate_and_consume(self, refresh_token: str) -> uuid.UUID:
"""Проверить refresh JWT и его наличие в Redis, затем сразу удалить (ротация)."""
try:
payload = decode_token(refresh_token)
except jwt.PyJWTError as exc:
raise InvalidRefreshTokenError from exc
if payload.get("type") != "refresh":
raise InvalidRefreshTokenError
jti = payload.get("jti")
sub = payload.get("sub")
if not jti or not sub:
raise InvalidRefreshTokenError
redis_key = f"{REFRESH_KEY_PREFIX}{jti}"
stored_user_id = await self._redis.get(redis_key)
if stored_user_id is None or stored_user_id != sub:
raise InvalidRefreshTokenError
# Немедленное удаление использованного jti: повторное предъявление
# того же refresh-токена (reuse) после этой точки всегда даст 401.
await self._redis.delete(redis_key)
return uuid.UUID(sub)
async def _issue_token_pair(self, user_id: uuid.UUID, role: str) -> TokenPair:
access_token = create_access_token(user_id, role)
refresh_token, jti = create_refresh_token(user_id)
ttl_seconds = self._settings.refresh_token_ttl_days * 24 * 3600
await self._redis.set(f"{REFRESH_KEY_PREFIX}{jti}", str(user_id), ex=ttl_seconds)
return TokenPair(access_token=access_token, refresh_token=refresh_token)
async def _issue_verification_email(self, user: User) -> None:
token = secrets.token_urlsafe(32) # 256 бит случайности
expires_at = datetime.now(UTC) + timedelta(
hours=self._settings.email_verification_ttl_hours
)
self._session.add(
EmailVerificationToken(
user_id=user.id, token_hash=_hash_token(token), expires_at=expires_at
)
)
link = f"{self._settings.frontend_url}/verify-email?token={token}"
await self._email_backend.send(
to=user.email,
subject="Подтверждение регистрации VidConf",
body=f"Для подтверждения email перейдите по ссылке: {link}",
)
def _hash_token(token: str) -> str:
"""Захэшировать токен подтверждения email алгоритмом sha256 (hex-строка)."""
return hashlib.sha256(token.encode()).hexdigest()

123
backend/services/avatars.py Normal file
View File

@@ -0,0 +1,123 @@
"""Хранение аватаров пользователей: валидация загрузки, файлы на диске, URL.
Файл лежит на диске `MEDIA_ROOT/avatars/{user_id}.{ext}`; в БД (`users.avatar_path`)
хранится путь относительно `MEDIA_ROOT` (`avatars/{user_id}.{ext}`) — тот же
приём, что и у записей аудиотреков (`recordings_dir`, `core/config.py`).
"""
import uuid
from collections.abc import Callable
from pathlib import Path
from fastapi import UploadFile
# Лимит размера загружаемого аватара — 2 МБ.
MAX_AVATAR_SIZE_BYTES = 2 * 1024 * 1024
# Читаем файл чанками, не доверяя заголовку `Content-Length` (клиент может
# солгать о размере) — реальный размер считается по факту прочитанных байт.
_CHUNK_SIZE_BYTES = 64 * 1024
# Допустимые типы изображений -> расширение файла на диске.
_ALLOWED_CONTENT_TYPES: dict[str, str] = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
}
# Магические байты (сигнатуры) форматов — заголовку `Content-Type` от клиента
# доверять нельзя (легко подделать), реальный формат определяется по
# содержимому файла.
_MAGIC_CHECKS: dict[str, Callable[[bytes], bool]] = {
"image/jpeg": lambda head: head[:3] == b"\xff\xd8\xff",
"image/png": lambda head: head[:8] == b"\x89PNG\r\n\x1a\n",
"image/webp": lambda head: head[:4] == b"RIFF" and head[8:12] == b"WEBP",
}
# Достаточно первых 12 байт, чтобы проверить все сигнатуры выше (WebP —
# самая длинная проверка, требует байты 8..11 включительно).
_MAGIC_HEAD_SIZE = 12
class AvatarTooLargeError(Exception):
"""Загружаемый файл превышает `MAX_AVATAR_SIZE_BYTES` (413)."""
class AvatarInvalidTypeError(Exception):
"""`Content-Type` не входит в список допустимых либо не совпадает с содержимым (415)."""
async def read_and_validate_avatar(file: UploadFile) -> tuple[bytes, str]:
"""Прочитать содержимое файла аватара чанками и провалидировать тип/размер.
Возвращает `(содержимое, расширение)`. Порядок проверок: сначала
заявленный `Content-Type` (быстрый отсев), затем фактический размер по
мере чтения, затем магические байты содержимого — заявленный тип должен
совпасть с реальным (иначе подделка `Content-Type` не даст загрузить,
например, исполняемый файл под видом `image/png`).
"""
declared_type = file.content_type
if declared_type not in _ALLOWED_CONTENT_TYPES:
raise AvatarInvalidTypeError(f"unsupported_content_type: {declared_type}")
chunks: list[bytes] = []
total_size = 0
while True:
chunk = await file.read(_CHUNK_SIZE_BYTES)
if not chunk:
break
total_size += len(chunk)
if total_size > MAX_AVATAR_SIZE_BYTES:
raise AvatarTooLargeError(f"file exceeds {MAX_AVATAR_SIZE_BYTES} bytes")
chunks.append(chunk)
content = b"".join(chunks)
magic_check = _MAGIC_CHECKS[declared_type]
if not magic_check(content[:_MAGIC_HEAD_SIZE]):
raise AvatarInvalidTypeError("content_does_not_match_declared_content_type")
return content, _ALLOWED_CONTENT_TYPES[declared_type]
def _avatar_relative_path(user_id: uuid.UUID, ext: str) -> str:
"""Путь аватара относительно `MEDIA_ROOT`."""
return f"avatars/{user_id}.{ext}"
def save_avatar(
media_root: Path, user_id: uuid.UUID, content: bytes, ext: str, *, old_path: str | None
) -> str:
"""Сохранить содержимое аватара на диск, удалить предыдущий файл (если был другого формата).
Возвращает новый относительный путь (`users.avatar_path`).
"""
avatars_dir = media_root / "avatars"
avatars_dir.mkdir(parents=True, exist_ok=True)
delete_avatar(media_root, old_path)
relative_path = _avatar_relative_path(user_id, ext)
(media_root / relative_path).write_bytes(content)
return relative_path
def delete_avatar(media_root: Path, avatar_path: str | None) -> None:
"""Удалить файл аватара с диска, если он существует; `None`/отсутствие файла — no-op."""
if not avatar_path:
return
file_path = media_root / avatar_path
file_path.unlink(missing_ok=True)
def avatar_url(media_root: Path, avatar_path: str | None) -> str | None:
"""URL аватара с cache-busting параметром `v={mtime файла}`; `None`, если аватара нет.
`mtime` — не хранящееся в БД значение (файл может быть перезалит в обход
ORM, например, вручную на проде), поэтому считывается со диска на лету.
"""
if not avatar_path:
return None
file_path = media_root / avatar_path
try:
mtime = int(file_path.stat().st_mtime)
except FileNotFoundError:
return None
return f"/media/{avatar_path}?v={mtime}"

218
backend/services/chat.py Normal file
View File

@@ -0,0 +1,218 @@
"""Бизнес-логика WS-чата конференции: аутентификация LiveKit-токеном, история, publish.
Единая аутентификация для пользователей и гостей — LiveKit access-токен
(`livekit.api.TokenVerifier`), а не backend-JWT: у гостя backend-JWT нет
вовсе (ADR-001, п.6), только LiveKit-токен, выданный при входе
(`services/livekit_tokens.py`). Grant `video.room` доказывает допуск именно
в эту конференцию — совпадение с `conference.slug` (имя LiveKit-комнаты,
ADR-001, п.4).
"""
import logging
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from livekit import api
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from core.redis import redis_client
from models.chat import ChatMessage
from models.conference import Conference
from repositories.chat import ChatMessageRepository
from repositories.conferences import ConferenceRepository, ConferenceSessionRepository
from schemas.chat import ChatMessageOut
from services.instance_settings import InstanceSettingsService
logger = logging.getLogger(__name__)
# Последние N сообщений открытой сессии, отправляемых новому подключению.
CHAT_HISTORY_LIMIT = 50
# Префикс identity гостя в LiveKit-токене (см. `services/webhook_handlers.py`).
GUEST_IDENTITY_PREFIX = "guest:"
def chat_channel(conference_id: uuid.UUID) -> str:
"""Имя Redis pub/sub канала чата конкретной конференции."""
return f"chat:{conference_id}"
class ChatAuthError(Exception):
"""Базовая ошибка допуска WS-подключения к чату — несёт WS close-код."""
def __init__(self, close_code: int) -> None:
self.close_code = close_code
super().__init__(close_code)
class InvalidTokenError(ChatAuthError):
"""Нет auth-сообщения, таймаут, невалидный/нераспознанный LiveKit-токен (close 4401)."""
def __init__(self) -> None:
super().__init__(4401)
class WrongRoomError(ChatAuthError):
"""Токен валиден, но выдан не для этой конференции (close 4403)."""
def __init__(self) -> None:
super().__init__(4403)
class ChatUnavailableError(ChatAuthError):
"""Чат выключен настройкой инстанса ИЛИ конференция не найдена/завершена.
Единый close-код 4404 для обоих случаев (номер/
ссылку конференции не перебираем, наличие конкретной конференции не
палим отдельным кодом).
"""
def __init__(self) -> None:
super().__init__(4404)
@dataclass(frozen=True)
class ChatIdentity:
"""Идентичность автора сообщения, восстановленная из LiveKit access-токена."""
user_id: uuid.UUID | None
guest_access_id: uuid.UUID | None
author_name: str
room: str
class ChatService:
"""Инкапсулирует сценарии WS-чата: auth, допуск, история, persist+publish."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
self._conferences = ConferenceRepository(session)
self._sessions = ConferenceSessionRepository(session)
self._messages = ChatMessageRepository(session)
async def authenticate(self, token: str) -> ChatIdentity:
"""Проверить LiveKit access-токен и восстановить identity автора сообщений.
Любая ошибка формата/подписи токена — единообразный `InvalidTokenError`
(детали причины не раскрываются клиенту).
"""
settings = get_settings()
verifier = api.TokenVerifier(settings.livekit_api_key, settings.livekit_api_secret)
try:
claims = verifier.verify(token)
except Exception as exc: # noqa: BLE001 — любая ошибка JWT/формата токена = 4401
raise InvalidTokenError from exc
if claims.video is None or not claims.video.room or not claims.identity:
raise InvalidTokenError
identity = claims.identity
room = claims.video.room
if identity.startswith(GUEST_IDENTITY_PREFIX):
try:
guest_id = uuid.UUID(identity.removeprefix(GUEST_IDENTITY_PREFIX))
except ValueError as exc:
raise InvalidTokenError from exc
return ChatIdentity(
user_id=None, guest_access_id=guest_id, author_name=claims.name, room=room
)
try:
user_id = uuid.UUID(identity)
except ValueError as exc:
raise InvalidTokenError from exc
return ChatIdentity(
user_id=user_id, guest_access_id=None, author_name=claims.name, room=room
)
async def ensure_chat_open(
self, conference_id: uuid.UUID, *, identity: ChatIdentity
) -> Conference:
"""Проверить допуск identity к чату конкретной конференции; вернуть конференцию.
Порядок проверок важен: тоггл и существование/статус конференции —
единый `ChatUnavailableError` (4404, инвариант №2), несовпадение
комнаты токена — отдельный `WrongRoomError` (4403), но только после
того, как убедились, что сама конференция легитимна.
"""
cfg = await InstanceSettingsService(self._session).get()
if not cfg.chat.enabled:
raise ChatUnavailableError
conference = await self._conferences.get_by_id(conference_id)
if conference is None or conference.status == "ended":
raise ChatUnavailableError
if identity.room != conference.slug:
raise WrongRoomError
return conference
async def history(self, conference: Conference) -> list[ChatMessageOut]:
"""Последние сообщения открытой сессии конференции (пусто, если сессии ещё нет)."""
session_record = await self._sessions.get_open_by_conference(conference.id)
if session_record is None:
return []
rows = await self._messages.last_for_session(session_record.id, limit=CHAT_HISTORY_LIMIT)
return [_to_out(row) for row in rows]
async def persist_and_publish(
self, conference: Conference, *, identity: ChatIdentity, text: str
) -> None:
"""Сохранить сообщение в открытой сессии конференции и опубликовать его в Redis.
Допуск проверяется только при коннекте (`ensure_chat_open`), а WS
(с LiveKit-токеном TTL 6 часов, `services/livekit_tokens.py`) может
жить намного дольше одной конференции — клиент способен слать
сообщения уже ПОСЛЕ `room_finished`. Если открытой сессии нет, брать
для решения "можно ли создать новую" статус из уже загруженного
объекта `conference` нельзя (`expire_on_commit=False`, объект мог
устареть за время жизни WS-сессии) — статус перечитывается свежим
SELECT (`ConferenceRepository.get_status_by_id`, минует identity map).
Для `ended`-конференции — `ChatUnavailableError` (close 4404),
сообщение отклоняется, новая "фантомная" сессия НЕ создаётся
(идемпотентность пайплайна). Если открытая
сессия уже есть (обычный случай) — пишем в неё без пересчёта статуса.
Порядок обязателен: сначала INSERT+commit в БД, потом publish —
отправитель получает своё сообщение обратно через pub/sub-echo,
порядок доставки единый у всех подписчиков канала.
"""
session_record = await self._sessions.get_open_by_conference(conference.id)
if session_record is None:
fresh_status = await self._conferences.get_status_by_id(conference.id)
if fresh_status is None or fresh_status == "ended":
raise ChatUnavailableError
session_record = await self._sessions.create(
conference_id=conference.id, title=conference.title, t_start=datetime.now(UTC)
)
message = await self._messages.add(
session_id=session_record.id,
user_id=identity.user_id,
guest_access_id=identity.guest_access_id,
author_name=identity.author_name,
text=text,
)
await self._session.commit()
payload = _to_out(message)
await redis_client.publish(chat_channel(conference.id), payload.model_dump_json())
def _to_out(message: ChatMessage) -> ChatMessageOut:
"""Собрать `ChatMessageOut` из ORM-строки сообщения."""
if message.user_id is not None:
author_id: str | None = str(message.user_id)
elif message.guest_access_id is not None:
author_id = str(message.guest_access_id)
else:
author_id = None
return ChatMessageOut(
id=message.id,
author_id=author_id,
author_name=message.author_name,
is_guest=message.guest_access_id is not None,
text=message.text,
created_at=message.created_at,
)

View File

@@ -0,0 +1,78 @@
"""Проверка права входа в конференцию (статус/пароль) и генерация ответа join.
Заменяет `services/room_access.py`: комнаты больше не конкурируют за
время («правило часа» удалено вместе с бронированием, ADR-001, п.5), но
пароль закрытой конференции по-прежнему проверяется здесь же — для обычного
пользователя и гостя одинаково.
"""
import json
from core.config import get_settings
from core.security import verify_password
from models.conference import Conference
from schemas.conferences import JoinOut
from services.livekit_tokens import create_room_access_token
class ConferenceEndedError(Exception):
"""Конференция завершена (терминальный статус) — повторный вход невозможен."""
class PasswordRequiredError(Exception):
"""Конференция закрыта паролем; пароль не передан."""
class InvalidPasswordError(Exception):
"""Указанный пароль не совпадает с паролем закрытой конференции."""
def ensure_joinable(conference: Conference, *, password: str | None) -> None:
"""Проверить, что в конференцию можно войти прямо сейчас.
Бросает `ConferenceEndedError` для терминального статуса `ended`
(история и саммари остаются, но повторный вход невозможен — ADR-001,
п.2), либо `PasswordRequiredError`/`InvalidPasswordError` для закрытой
паролем конференции. Ничего не бросает для открытой конференции в
статусе `scheduled`/`active`.
"""
if conference.status == "ended":
raise ConferenceEndedError
if not conference.is_closed:
return
if conference.password_hash is None or password is None:
raise PasswordRequiredError
if not verify_password(password, conference.password_hash):
raise InvalidPasswordError
def build_join(
conference: Conference,
*,
identity: str,
name: str,
chat_enabled: bool,
avatar_url: str | None = None,
) -> JoinOut:
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции.
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
`chat_enabled` — снятый вызывающей стороной тоггл `instance_settings`:
читается здесь параметром, а не заново из БД, чтобы не плодить
отдельный запрос настроек на каждый join. `avatar_url` прокидывается
в метаданные токена как JSON
`{"avatar_url": ...}`; `None` (гость либо пользователь без аватара) —
метаданные не выставляются вовсе.
"""
settings = get_settings()
metadata = json.dumps({"avatar_url": avatar_url}) if avatar_url else None
token = create_room_access_token(
room_name=conference.slug, identity=identity, name=name, metadata=metadata
)
return JoinOut(
livekit_url=settings.livekit_public_url,
token=token,
room_name=conference.slug,
conference_id=conference.id,
chat_enabled=chat_enabled,
)

View File

@@ -0,0 +1,37 @@
"""Генерация номера и постоянной ссылки конференции (ADR-001, п.4).
Оба идентификатора неизменны всё время жизни конференции и не переиспользуются;
уникальность в БД обеспечивают constraint'ы `conferences.number`/`conferences.slug`,
коллизии (крайне маловероятные при выбранной энтропии) обрабатываются повторной
генерацией на уровне репозитория/сервиса, создающего конференцию.
"""
import secrets
# Длина номера конференции в десятичных цифрах.
NUMBER_LENGTH = 9
# Длина slug в байтах энтропии (до base64url-кодирования); 8 байт = 64 бита,
# что даёт 11 символов base64url без padding.
SLUG_ENTROPY_BYTES = 8
def generate_number() -> str:
"""Сгенерировать 9-значный номер конференции.
Первая цифра — 1..9 (число не начинается с нуля), остальные 8 — 0..9.
Используется `secrets.randbelow` — криптографически стойкий генератор,
подбор номера должен быть неугадываем (оценка энтропии — ADR-001, п.4).
"""
first_digit = str(secrets.randbelow(9) + 1)
rest_digits = "".join(str(secrets.randbelow(10)) for _ in range(NUMBER_LENGTH - 1))
return first_digit + rest_digits
def generate_slug() -> str:
"""Сгенерировать slug постоянной ссылки конференции (`/j/{slug}`).
`secrets.token_urlsafe(8)` — 8 байт (64 бита) энтропии, 11 символов
base64url; также используется как имя LiveKit-комнаты.
"""
return secrets.token_urlsafe(SLUG_ENTROPY_BYTES)

View File

@@ -0,0 +1,670 @@
"""Бизнес-логика конференций: создание, «Мои конференции», календарь, резолв, join, правки.
Тонкий API-роутер (`api/conferences.py`) делегирует сюда всю логику; проверки
статуса/пароля и генерация ответа join вынесены в `services/conference_access.py`.
"""
import logging
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, cast
from sqlalchemy import null
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from core.plugins.config import SummaryRecipientsMode
from core.security import hash_password
from models.conference import Conference
from models.guest import GuestAccess
from models.invitee import ConferenceInvitee
from models.user import User
from repositories.conferences import ConferenceInviteeRepository, ConferenceRepository
from schemas.conferences import (
ConferenceCreateIn,
ConferenceOut,
ConferenceUpdateIn,
GuestJoinIn,
InviteeIn,
InviteeOut,
JoinOut,
OccurrenceOut,
)
from services.avatars import avatar_url as resolve_avatar_url
from services.conference_access import build_join, ensure_joinable
from services.conference_ids import generate_number, generate_slug
from services.instance_settings import InstanceSettingsService
from services.invitations_producer import enqueue_invitations
from services.recurrence import RecurrenceRule, expand_occurrences
logger = logging.getLogger(__name__)
# Число попыток сгенерировать уникальные номер/slug при коллизии unique-constraint
# (крайне маловероятной при выбранной энтропии — см. ADR-001, п.4).
MAX_ID_GENERATION_ATTEMPTS = 5
# Горизонт поиска "следующего вхождения" закреплённой конференции с повторением
# для «Моих конференций»; с запасом покрывает самый частый шаг (weekly/monthly).
NEXT_OCCURRENCE_HORIZON = timedelta(days=400)
# Длительность вхождения календаря по умолчанию, если у разовой плановой
# конференции не указан `duration_minutes`.
DEFAULT_OCCURRENCE_DURATION_MINUTES = 60
class ConferenceNotFoundError(Exception):
"""Конференция с таким id не найдена."""
class NotConferenceOwnerError(Exception):
"""Действие разрешено только владельцу конференции или администратору."""
class ConferenceActiveError(Exception):
"""Нельзя удалить конференцию, которая сейчас активна."""
class InvalidConferenceStateError(Exception):
"""Итоговое состояние конференции после правки нарушает бизнес-инварианты."""
class InviteeUserNotFoundError(Exception):
"""Один или несколько `user_id` в составе приглашённых не существуют (ADR-003)."""
def __init__(self, missing_user_ids: set[uuid.UUID]) -> None:
self.missing_user_ids = missing_user_ids
super().__init__(f"неизвестные user_id приглашённых: {missing_user_ids}")
class ConferenceService:
"""Инкапсулирует сценарии создания/просмотра/входа/правки конференций."""
def __init__(self, session: AsyncSession, *, media_root: Path | None = None) -> None:
self._session = session
self._conferences = ConferenceRepository(session)
self._invitees = ConferenceInviteeRepository(session)
self._media_root = media_root or Path(get_settings().media_root)
async def create(
self, *, owner: User, data: ConferenceCreateIn
) -> tuple[Conference, JoinOut | None]:
"""Создать конференцию.
Мгновенная (`status=active`, создатель входит сразу же — второй
элемент кортежа тогда содержит готовый `JoinOut`) — только если не
указаны ни `scheduled_at`, ни `recurrence`; закреплённая с
повторением без явного `scheduled_at` — плановая конференция,
ожидающая своего первого вхождения, а не мгновенный вход.
"""
password_hash = hash_password(data.password) if data.password else None
is_instant = data.scheduled_at is None and data.recurrence is None
conference_status = "active" if is_instant else "scheduled"
recurrence_json = data.recurrence.model_dump(mode="json") if data.recurrence else None
# Примитивы читаются из `owner` один раз, до цикла retry: после
# `session.rollback()` (при коллизии number/slug) ORM помечает все
# загруженные объекты, включая `owner`, протухшими, а повторное
# обращение к их атрибутам вне greenlet-контекста упало бы с
# `MissingGreenlet` (ленивая подгрузка синхронным геттером).
owner_id = owner.id
owner_name = owner.name_user
owner_email = owner.email
owner_avatar_path = owner.avatar_path
# Раннее падение при неизвестном `user_id` приглашённого — ДО
# создания конференции (см. `InviteeUserNotFoundError`), чтобы не
# оставлять во flush-буфере сессии несохранённую строку `conferences`.
await self._validate_participants(data.participants)
def _factory(number: str, slug: str) -> Conference:
conference = Conference(
number=number,
slug=slug,
title=data.title,
owner_id=owner_id,
status=conference_status,
is_pinned=data.is_pinned,
is_closed=data.is_closed,
password_hash=password_hash,
scheduled_at=data.scheduled_at,
duration_minutes=data.duration_minutes,
summary_recipients=data.summary_recipients,
)
# Атрибут намеренно не устанавливается вовсе, если правила нет:
# JSONB-колонка сериализует явно присвоенный Python `None` в
# JSON-литерал `null`, а не в SQL NULL (нет `none_as_null=True` —
# модель не трогаем, см. блок A); неустановленный атрибут при
# INSERT просто опускается, и колонка получает настоящий NULL.
if recurrence_json is not None:
conference.recurrence = recurrence_json
return conference
conference = await self._create_with_unique_ids(_factory)
await self._apply_participants(conference, data.participants, owner_email=owner_email)
await self._session.commit()
join = None
if is_instant:
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
join = build_join(
conference,
identity=str(owner_id),
name=owner_name,
chat_enabled=chat_enabled,
avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
)
else:
# Плановая (разовая) либо закреплённая с повторением/датой — есть
# расписание, на которое имеет смысл прислать .ics-приглашение
# («.ics»). Ставится ПОСЛЕ commit — воркер должен
# видеть уже зафиксированную строку конференции.
self._enqueue_invitations_safely(conference.id)
return conference, join
async def list_my(self, *, owner: User) -> list[ConferenceOut]:
"""Закреплённые конференции + предстоящие разовые владельца ИЛИ приглашённого.
Решение от 2026-07-20 (поверх ADR-003): приглашённый должен видеть
конференцию в своих списках — `owner` здесь означает «текущий
пользователь», а не только фактического владельца конференции.
`participants` НЕ заполняется (пусто) — список не раздувает состав
(ADR-003, п.5); `organizer_name`/`is_owner` теперь честно вычисляются
на конференцию (для приглашённого — имя РЕАЛЬНОГО владельца и
`is_owner=False`), а не берутся из `owner` — небольшой N+1 на строки,
где владелец конференции не совпадает с viewer (`_resolve_organizer_name`),
список короткий.
"""
now = datetime.now(UTC)
conferences = await self._conferences.list_owned(owner.id, email=owner.email, now=now)
result: list[ConferenceOut] = []
for conference in conferences:
organizer_name = await self._resolve_organizer_name(conference, viewer=owner)
result.append(
self.to_out(
conference,
next_occurrence=self._next_occurrence(conference, now=now),
viewer_id=owner.id,
organizer_name=organizer_name,
)
)
return result
async def list_calendar(
self, *, owner: User, t_from: datetime, t_to: datetime
) -> list[OccurrenceOut]:
"""Развернуть вхождения закреплённых/разовых конференций владельца ИЛИ приглашённого
(решение от 2026-07-20 поверх ADR-003 — тот же принцип видимости, что `list_my`)."""
candidates = await self._conferences.list_calendar_candidates(owner.id, email=owner.email)
occurrences: list[OccurrenceOut] = []
for conference in candidates:
if conference.recurrence is not None:
rule = RecurrenceRule.model_validate(conference.recurrence)
for starts_at in expand_occurrences(rule, t_from, t_to):
occurrences.append(
self._occurrence_out(
conference,
starts_at=starts_at,
ends_at=starts_at + timedelta(minutes=rule.duration_minutes),
)
)
elif conference.scheduled_at is not None and t_from <= conference.scheduled_at <= t_to:
duration = conference.duration_minutes or DEFAULT_OCCURRENCE_DURATION_MINUTES
occurrences.append(
self._occurrence_out(
conference,
starts_at=conference.scheduled_at,
ends_at=conference.scheduled_at + timedelta(minutes=duration),
)
)
occurrences.sort(key=lambda occurrence: occurrence.starts_at)
return occurrences
async def resolve(self, q: str) -> Conference | None:
"""Найти конференцию по slug или номеру (пробелы в номере игнорируются)."""
query = q.strip()
if not query:
return None
conference = await self._conferences.get_by_slug(query)
if conference is not None:
return conference
number_candidate = query.replace(" ", "")
if number_candidate.isdigit():
return await self._conferences.get_by_number(number_candidate)
return None
async def join_as_user(
self, conference_id: uuid.UUID, *, user: User, password: str | None
) -> JoinOut:
"""Войти в конференцию зарегистрированным пользователем."""
conference = await self._get_or_raise(conference_id)
ensure_joinable(conference, password=password)
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
return build_join(
conference,
identity=str(user.id),
name=user.name_user,
chat_enabled=chat_enabled,
avatar_url=resolve_avatar_url(self._media_root, user.avatar_path),
)
async def join_as_guest(self, conference_id: uuid.UUID, *, data: GuestJoinIn) -> JoinOut:
"""Войти в конференцию гостем: создать `GuestAccess` и выдать токен."""
conference = await self._get_or_raise(conference_id)
ensure_joinable(conference, password=data.password)
guest = GuestAccess(
conference_id=conference.id, display_name=data.display_name, email=data.email
)
self._session.add(guest)
await self._session.flush()
await self._session.commit()
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
return build_join(
conference,
identity=f"guest:{guest.id}",
name=data.display_name,
chat_enabled=chat_enabled,
)
async def update(
self, conference_id: uuid.UUID, *, actor: User, data: ConferenceUpdateIn
) -> Conference:
"""Частично обновить конференцию; разрешено владельцу или администратору."""
conference = await self._get_or_raise(conference_id)
self._ensure_owner_or_admin(conference, actor)
# Раннее падение при неизвестном `user_id` приглашённого — ДО любых
# мутаций конференции (см. `InviteeUserNotFoundError`).
await self._validate_participants(data.participants)
# Снимок «расписательных» полей ДО правки — чтобы после всех мутаций
# (включая recurrence, разрешаемый ниже отдельно) определить, нужно
# ли инкрементировать `ics_sequence` и переслать .ics-приглашение.
title_before = conference.title
scheduled_at_before = conference.scheduled_at
duration_before = conference.duration_minutes
if data.title is not None:
conference.title = data.title
if data.scheduled_at is not None:
conference.scheduled_at = data.scheduled_at
if data.duration_minutes is not None:
conference.duration_minutes = data.duration_minutes
if data.is_closed is not None:
conference.is_closed = data.is_closed
if data.password is not None:
conference.password_hash = hash_password(data.password)
if "summary_recipients" in data.model_fields_set:
# Явная передача (в т.ч. `null`) — сбросить/установить
# переопределение; отсутствие поля в запросе значение не трогает.
conference.summary_recipients = data.summary_recipients
is_pinned = data.is_pinned if data.is_pinned is not None else conference.is_pinned
conference.is_pinned = is_pinned
# Итоговое значение recurrence считаем как обычный Python dict|None —
# чтобы бизнес-проверка ниже не путала ORM-сентинел `null()` (см. далее)
# с «правила нет». `model_fields_set` различает «поле не передано» и
# «передано явным `null`» — оба случая иначе выглядят одинаково
# (`data.recurrence is None`).
current_recurrence = conference.recurrence
recurrence_explicitly_cleared = (
"recurrence" in data.model_fields_set and data.recurrence is None
)
# Открепление обязано снимать и правило повторения: recurrence без
# is_pinned нарушает CHECK-constraint `ck_conferences_recurrence_requires_pinned`,
# даже если PATCH вовсе не упоминал `recurrence` (например, только
# `{"is_pinned": false}`).
must_clear = recurrence_explicitly_cleared or (
not is_pinned and current_recurrence is not None
)
cleared_via_null_sentinel = False
if data.recurrence is not None:
recurrence: dict[str, Any] | None = data.recurrence.model_dump(mode="json")
conference.recurrence = recurrence
elif must_clear:
recurrence = None
# JSONB-колонка без `none_as_null=True` (модель не трогаем, см.
# блок A) сериализует явно присвоенный Python `None` в
# JSON-литерал `null`, а не в SQL NULL — что уронило бы
# CHECK-constraint. `sqlalchemy.null()` форсирует настоящий SQL
# NULL, но помечает атрибут "expired" после flush (ORM не знает
# результат SQL-выражения без похода в БД) — обновляем объект
# явным awaited-рефрешем ниже перед возвратом, иначе синхронное
# чтение `conference.recurrence` вне greenlet (например, в
# `to_out`) упадёт `MissingGreenlet`.
conference.recurrence = cast(Any, null())
cleared_via_null_sentinel = True
else:
# Ничего менять не нужно — атрибут не трогаем вовсе, чтобы не
# провоцировать лишнюю expiry на пустом месте (см. выше).
recurrence = current_recurrence
if conference.is_closed and conference.password_hash is None:
raise InvalidConferenceStateError("closed_conference_requires_password")
if recurrence is not None and not is_pinned:
raise InvalidConferenceStateError("recurrence_requires_pinned")
# Расписание изменилось, если сдвинулись заголовок/дата/длительность
# либо правило повторения (сравниваем уже разрешённое значение
# `recurrence`, а не «сырой» атрибут — иначе сентинел `null()` ниже
# спутал бы сравнение). Такая правка обязана переслать .ics-приглашение
# с новым `SEQUENCE` — иначе уже принятое
# приглашение в календаре адресата разойдётся с фактическим временем.
schedule_changed = (
conference.title != title_before
or conference.scheduled_at != scheduled_at_before
or conference.duration_minutes != duration_before
or recurrence != current_recurrence
)
if schedule_changed:
conference.ics_sequence += 1
# Состав приглашённых — после всех проверок бизнес-инвариантов выше
# (иначе при `InvalidConferenceStateError` DELETE/INSERT состава
# пришлось бы объяснять исполнением, которое всё равно не закоммитится
# — но проще просто не выполнять его до финальной валидации).
# `ics_sequence` НЕ растёт от одной только правки состава
# (анти-спам) — рассылка всё равно ставится в очередь.
owner_email = await self._resolve_owner_email(conference)
participants_changed = await self._apply_participants(
conference, data.participants, owner_email=owner_email
)
await self._session.flush()
await self._session.commit()
if cleared_via_null_sentinel:
await self._session.refresh(conference, attribute_names=["recurrence"])
if schedule_changed or participants_changed:
# Ставится ПОСЛЕ commit — воркер должен видеть уже
# зафиксированные `ics_sequence`/новое расписание/состав.
self._enqueue_invitations_safely(conference.id)
return conference
def _enqueue_invitations_safely(self, conference_id: uuid.UUID) -> None:
"""Поставить рассылку .ics-приглашений в очередь, не роняя запрос при сбое.
Конференция к этому моменту уже закоммичена — создание/правка это
главный результат запроса, а рассылка приглашений вторична. При
недоступности Redis `Celery.send_task` (используется
`enqueue_invitations`) бросает исключение синхронно — без этого перехвата
POST/PATCH `/conferences` отдал бы 500, хотя запись уже сохранена.
Восстановление: пропавшая постановка компенсируется ручной рассылкой
администратором (`POST /admin/conferences/{id}/invitations`) — в
отличие от шагов AI-пайплайна, здесь нет beat-задачи уровня 2 защиты,
т.к. отсутствие приглашения не блокирует использование конференции.
"""
try:
enqueue_invitations(conference_id)
except Exception: # noqa: BLE001 — недоступность брокера не должна ронять запрос
logger.warning(
"ConferenceService: не удалось поставить в очередь рассылку "
"приглашений для конференции %s (Redis недоступен?) — конференция "
"сохранена, разослать приглашения можно вручную из админки",
conference_id,
exc_info=True,
)
async def delete(self, conference_id: uuid.UUID, *, actor: User) -> None:
"""Удалить конференцию; запрещено для активной (409 на уровне роутера)."""
conference = await self._get_or_raise(conference_id)
self._ensure_owner_or_admin(conference, actor)
if conference.status == "active":
raise ConferenceActiveError
await self._conferences.delete(conference)
await self._session.commit()
async def get_detail(self, conference_id: uuid.UUID, *, actor: User) -> Conference:
"""Получить конференцию для детального просмотра.
Разрешено владельцу, администратору ИЛИ приглашённому (решение от
2026-07-20 поверх ADR-003 — иначе ховер-карточка/детальная страница
приглашённого получали бы 403). PATCH/DELETE эту проверку НЕ
переиспользуют — там по-прежнему только владелец/администратор
(`_ensure_owner_or_admin`).
"""
conference = await self._get_or_raise(conference_id)
await self._ensure_viewable(conference, actor)
return conference
async def to_detail_out(
self, conference: Conference, *, viewer: User, join: JoinOut | None = None
) -> ConferenceOut:
"""Собрать `ConferenceOut` с полным составом участников (детальный ответ).
Используется создание/правка/`GET /conferences/{id}` — списочные
эндпоинты (`list_my`) состав не раздувают (ADR-003, п.5) и строят
ответ через `to_out` напрямую.
"""
organizer_name = await self._resolve_organizer_name(conference, viewer=viewer)
participants = await self._build_participants_out(conference)
return self.to_out(
conference,
join=join,
organizer_name=organizer_name,
participants=participants,
viewer_id=viewer.id,
)
def to_out(
self,
conference: Conference,
*,
join: JoinOut | None = None,
next_occurrence: datetime | None = None,
viewer_id: uuid.UUID | None = None,
organizer_name: str | None = None,
participants: list[InviteeOut] | None = None,
) -> ConferenceOut:
"""Собрать `ConferenceOut` из ORM-модели."""
recurrence = (
RecurrenceRule.model_validate(conference.recurrence) if conference.recurrence else None
)
return ConferenceOut(
id=conference.id,
number=conference.number,
slug=conference.slug,
title=conference.title,
status=conference.status,
is_pinned=conference.is_pinned,
is_closed=conference.is_closed,
scheduled_at=conference.scheduled_at,
duration_minutes=conference.duration_minutes,
recurrence=recurrence,
next_occurrence=next_occurrence,
created_at=conference.created_at,
join=join,
summary_recipients=cast("SummaryRecipientsMode | None", conference.summary_recipients),
owner_id=conference.owner_id,
is_owner=viewer_id is not None and conference.owner_id == viewer_id,
organizer_name=organizer_name,
participants=participants or [],
)
async def _resolve_organizer_name(self, conference: Conference, *, viewer: User) -> str | None:
"""Имя организатора; переиспользует уже загруженного `viewer`, если он и есть владелец."""
if conference.owner_id is None:
return None
if conference.owner_id == viewer.id:
return viewer.name_user
return await self._resolve_owner_name(conference)
async def _resolve_owner_name(self, conference: Conference) -> str | None:
if conference.owner_id is None:
return None
owner = await self._session.get(User, conference.owner_id)
return owner.name_user if owner is not None else None
async def _resolve_owner_email(self, conference: Conference) -> str | None:
if conference.owner_id is None:
return None
owner = await self._session.get(User, conference.owner_id)
return owner.email if owner is not None else None
async def _build_participants_out(self, conference: Conference) -> list[InviteeOut]:
"""Состав приглашённых: организатор всегда первым (ADR-003, п.2), затем остальные."""
items: list[InviteeOut] = []
if conference.owner_id is not None:
owner = await self._session.get(User, conference.owner_id)
if owner is not None:
items.append(
InviteeOut(
user_id=owner.id,
email=owner.email,
name=owner.name_user,
avatar_url=resolve_avatar_url(self._media_root, owner.avatar_path),
is_organizer=True,
)
)
rows = await self._invitees.list_with_user(conference.id)
for invitee, user_name, user_email, user_avatar_path in rows:
items.append(
InviteeOut(
user_id=invitee.user_id,
email=invitee.email if invitee.email is not None else user_email,
name=user_name,
avatar_url=(
resolve_avatar_url(self._media_root, user_avatar_path)
if invitee.user_id is not None
else None
),
is_organizer=False,
)
)
return items
async def _validate_participants(self, participants: list[InviteeIn] | None) -> None:
"""Проверить существование всех `user_id` состава — ДО любых мутаций (ADR-003)."""
if participants is None:
return
user_ids = {item.user_id for item in participants if item.user_id is not None}
if not user_ids:
return
missing = user_ids - await self._invitees.existing_user_ids(user_ids)
if missing:
raise InviteeUserNotFoundError(missing)
async def _apply_participants(
self,
conference: Conference,
participants: list[InviteeIn] | None,
*,
owner_email: str | None,
) -> bool:
"""Заменить состав приглашённых; вернуть `True`, если фактический состав изменился.
`participants=None` — не менять (ADR-003, п.3), no-op. Организатор
(по `user_id` владельца или его email) молча дедуплицируется — он и
так всегда в составе (ADR-003, п.2), а неудаляем конструктивно.
`_validate_participants` должен быть вызван заранее.
"""
if participants is None:
return False
owner_id = conference.owner_id
desired: dict[tuple[str, str], ConferenceInvitee] = {}
for item in participants:
if item.user_id is not None:
if item.user_id == owner_id:
continue
key = ("user", str(item.user_id))
desired.setdefault(
key, ConferenceInvitee(conference_id=conference.id, user_id=item.user_id)
)
else:
assert item.email is not None # гарантировано `InviteeIn._validate`
# email пользователя в БД может быть в смешанном регистре,
# InviteeIn.email всегда нормализован в lower — сравниваем без регистра.
if owner_email is not None and item.email == owner_email.lower():
continue
key = ("email", item.email)
desired.setdefault(
key, ConferenceInvitee(conference_id=conference.id, email=item.email)
)
existing_rows = await self._invitees.list_with_user(conference.id)
existing_keys = {
("user", str(invitee.user_id))
if invitee.user_id is not None
else ("email", invitee.email)
for invitee, _, _, _ in existing_rows
}
changed = existing_keys != set(desired.keys())
await self._invitees.replace_all(conference.id, list(desired.values()))
return changed
async def _get_or_raise(self, conference_id: uuid.UUID) -> Conference:
conference = await self._conferences.get_by_id(conference_id)
if conference is None:
raise ConferenceNotFoundError
return conference
def _ensure_owner_or_admin(self, conference: Conference, actor: User) -> None:
if conference.owner_id != actor.id and actor.role != "admin":
raise NotConferenceOwnerError
async def _ensure_viewable(self, conference: Conference, actor: User) -> None:
"""Владелец/администратор/приглашённый — иначе `NotConferenceOwnerError` (403).
Только для чтения (`get_detail`) — решение от 2026-07-20 поверх
ADR-003; PATCH/DELETE используют `_ensure_owner_or_admin` (синхронный,
без приглашённых).
"""
if conference.owner_id == actor.id or actor.role == "admin":
return
if await self._invitees.exists_for_user(conference.id, user_id=actor.id, email=actor.email):
return
raise NotConferenceOwnerError
def _next_occurrence(self, conference: Conference, *, now: datetime) -> datetime | None:
if conference.recurrence is not None:
rule = RecurrenceRule.model_validate(conference.recurrence)
horizon = NEXT_OCCURRENCE_HORIZON
if rule.type == "every_n_days" and rule.interval_days is not None:
horizon = max(horizon, timedelta(days=rule.interval_days + 2))
occurrences = expand_occurrences(rule, now, now + horizon)
return occurrences[0] if occurrences else None
if conference.scheduled_at is not None and conference.scheduled_at >= now:
return conference.scheduled_at
return None
def _occurrence_out(
self, conference: Conference, *, starts_at: datetime, ends_at: datetime
) -> OccurrenceOut:
return OccurrenceOut(
conference_id=conference.id,
title=conference.title,
starts_at=starts_at,
ends_at=ends_at,
number=conference.number,
slug=conference.slug,
is_pinned=conference.is_pinned,
is_closed=conference.is_closed,
)
async def _create_with_unique_ids(
self, factory: Callable[[str, str], Conference]
) -> Conference:
"""Создать конференцию, повторяя генерацию номера/slug при коллизии unique.
До `MAX_ID_GENERATION_ATTEMPTS` попыток; откат до последнего
savepoint обязателен после `IntegrityError` — иначе сессия
SQLAlchemy становится непригодной для дальнейших операций (в т.ч. в
тестах со savepoint).
"""
last_error: IntegrityError | None = None
for _ in range(MAX_ID_GENERATION_ATTEMPTS):
conference = factory(generate_number(), generate_slug())
try:
return await self._conferences.add(conference)
except IntegrityError as exc:
await self._session.rollback()
last_error = exc
assert last_error is not None
raise last_error

View File

@@ -0,0 +1,58 @@
"""Тонкая обёртка над LiveKit `EgressService.start_track_egress` — единственная точка для
мокирования в тестах (по образцу `workers/livekit_client.py::delete_livekit_room`).
Запускает Track Egress для одного аудиотрека: пишет исходный opus без
транскодирования в `.ogg` на общий volume `recordings_dir`. Финализация
результата (итоговый `location`/ошибка) приходит асинхронно через webhook
`egress_ended` — здесь только сам запуск и `egress_id`/`started_at` из ответа.
"""
from dataclasses import dataclass
from datetime import UTC, datetime
from livekit import api
from core.config import get_settings
@dataclass(frozen=True, slots=True)
class EgressStartResult:
"""Результат запуска Track Egress: идентификатор задания и время старта (UTC)."""
egress_id: str
started_at: datetime
async def start_track_egress(room_name: str, track_sid: str, filepath: str) -> EgressStartResult:
"""Запустить запись одного аудиотрека комнаты в файл `filepath` на общем volume.
`DirectFileOutput` без указания облачного хранилища (s3/gcp/azure) пишет
файл напрямую на диск egress-контейнера — тот же volume `recordings_dir`,
что и у воркера транскрибации (см. `deploy/docker-compose.yml`).
"""
settings = get_settings()
lkapi = api.LiveKitAPI(
settings.livekit_url,
api_key=settings.livekit_api_key,
api_secret=settings.livekit_api_secret,
)
try:
info = await lkapi.egress.start_track_egress(
api.TrackEgressRequest(
room_name=room_name,
track_id=track_sid,
file=api.DirectFileOutput(filepath=filepath),
)
)
finally:
await lkapi.aclose()
# `EgressInfo.started_at` — unix-наносекунды (см. документацию livekit/egress,
# `pkg/config/manifest.go`); при отсутствии (ещё не проставлен на момент
# ответа STARTING) считаем стартом текущий момент.
started_at = (
datetime.fromtimestamp(info.started_at / 1_000_000_000, tz=UTC)
if info.started_at
else datetime.now(UTC)
)
return EgressStartResult(egress_id=info.egress_id, started_at=started_at)

202
backend/services/email.py Normal file
View File

@@ -0,0 +1,202 @@
"""Абстракция отправки email: контракт `EmailBackend`, dev- и SMTP-реализации.
Выбор бэкенда (`console`|`smtp`) — переменная окружения `EMAIL_BACKEND`
(`core/config.py`), не настройка в БД: секреты SMTP — только в `.env`,
а `SettingsOut` админки их не должен видеть.
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from dataclasses import dataclass
from email.message import EmailMessage
from typing import TYPE_CHECKING, Protocol
import aiosmtplib
if TYPE_CHECKING:
from core.config import Settings
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class EmailAttachment:
"""Вложение письма (например, `.ics`-приглашение, `text/calendar; method=REQUEST`)."""
filename: str
content: bytes
mime_type: str
class EmailSendError(Exception):
"""Ошибка отправки письма транспортом.
`retryable=True` — временный сбой транспорта (сервер недоступен/оборвал
соединение/таймаут): вызывающая Celery-задача должна повторить попытку.
`retryable=False` — конкретный получатель отклонён сервером (повторять
без изменения адреса бессмысленно) — вызывающая сторона пропускает его,
не роняя всю рассылку (см. `workers/tasks/notify.py`).
"""
def __init__(self, message: str, *, retryable: bool) -> None:
super().__init__(message)
self.retryable = retryable
class EmailBackend(Protocol):
"""Контракт отправки email; новые бэкенды подставляются без правки ядра."""
async def send(
self,
*,
to: str,
subject: str,
body: str,
html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (),
) -> None:
"""Отправить письмо получателю `to` (plaintext body обязателен, HTML — альтернатива)."""
...
class ConsoleEmailBackend:
"""Бэкенд для разработки: пишет письмо в лог вместо реальной отправки."""
async def send(
self,
*,
to: str,
subject: str,
body: str,
html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (),
) -> None:
"""Залогировать письмо (вложения — только имена файлов, без содержимого)."""
attachment_names = ", ".join(a.filename for a in attachments) or "нет"
logger.info(
"EMAIL to=%s subject=%s attachments=[%s]\n%s",
to,
subject,
attachment_names,
body,
)
class SmtpEmailBackend:
"""Бэкенд реальной отправки email через SMTP (`aiosmtplib.send`)."""
def __init__(
self,
*,
hostname: str,
port: int,
username: str | None,
password: str | None,
start_tls: bool,
use_tls: bool,
timeout: float,
sender: str,
) -> None:
self._hostname = hostname
self._port = port
self._username = username
self._password = password
self._start_tls = start_tls
self._use_tls = use_tls
self._timeout = timeout
self._sender = sender
async def send(
self,
*,
to: str,
subject: str,
body: str,
html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (),
) -> None:
"""Отправить письмо; ошибки транспорта транслируются в `EmailSendError`."""
message = _build_message(
sender=self._sender,
to=to,
subject=subject,
body=body,
html_body=html_body,
attachments=attachments,
)
try:
await aiosmtplib.send(
message,
hostname=self._hostname,
port=self._port,
username=self._username or None,
password=self._password or None,
start_tls=self._start_tls,
use_tls=self._use_tls,
timeout=self._timeout,
)
except aiosmtplib.SMTPRecipientsRefused as exc:
# Сервер отклонил конкретного получателя — повтор не поможет без
# изменения адреса; вызывающая сторона (notify_session) пропускает
# только его, не роняя рассылку остальным получателям.
logger.warning("SMTP: получатель %s отклонён сервером: %s", to, exc)
raise EmailSendError(f"получатель отклонён сервером: {to}", retryable=False) from exc
except (
aiosmtplib.SMTPConnectError,
aiosmtplib.SMTPServerDisconnected,
aiosmtplib.SMTPTimeoutError,
aiosmtplib.SMTPAuthenticationError,
) as exc:
# Временный сбой транспорта — стоит повторить попытку позже.
# `SMTPTimeoutError` после отправки DATA — доставка неизвестна:
# принят at-least-once, редкий дубль
# предпочтительнее потери письма.
logger.warning("SMTP: временный сбой при отправке на %s: %s", to, exc)
raise EmailSendError(f"временный сбой SMTP: {exc}", retryable=True) from exc
def _build_message(
*,
sender: str,
to: str,
subject: str,
body: str,
html_body: str | None,
attachments: Sequence[EmailAttachment],
) -> EmailMessage:
"""Собрать `EmailMessage`: plaintext (+ HTML-альтернатива) + вложения."""
message = EmailMessage()
message["From"] = sender
message["To"] = to
message["Subject"] = subject
message.set_content(body)
if html_body is not None:
message.add_alternative(html_body, subtype="html")
for attachment in attachments:
maintype, _, rest = attachment.mime_type.partition("/")
subtype = rest.split(";", 1)[0].strip() or "octet-stream"
message.add_attachment(
attachment.content,
maintype=maintype or "application",
subtype=subtype,
filename=attachment.filename,
)
return message
def create_email_backend(settings: Settings) -> EmailBackend:
"""Собрать бэкенд отправки email по `settings.email_backend` (`console` по умолчанию)."""
if settings.email_backend == "smtp":
return SmtpEmailBackend(
hostname=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_username,
password=settings.smtp_password,
start_tls=settings.smtp_start_tls,
use_tls=settings.smtp_use_tls,
timeout=settings.smtp_timeout_s,
sender=settings.smtp_from,
)
return ConsoleEmailBackend()

View File

@@ -0,0 +1,161 @@
"""Генератор письма с саммари встречи: plaintext + HTML.
HTML-версия — по мотивам макета `design/mockups/email-summary.html`
(упрощённая структура: шапка/участники/тело саммари/футер, цвета и типографика
светлой темы `design/DESIGN_SYSTEM.md`; почтовые клиенты игнорируют внешние
`<style>`, поэтому все стили — инлайн). Тело саммари приходит от LLM
(`conference_sessions.summary_data`) в фиксированном markdown-подобном
формате промпта `workers/summarizer/prompts/summary_reduce_ru.txt` (заголовки
`## ...`, пункты `- ...`) — при рендере разбирается построчно и оборачивается
в HTML-разметку; заголовок конференции, имена участников и сам текст саммари
экранируются `html.escape`.
Plaintext-альтернатива — обязательный минимум для клиентов без HTML.
"""
from __future__ import annotations
import html
from collections.abc import Sequence
from dataclasses import dataclass
@dataclass(frozen=True)
class SummaryEmailContext:
"""Данные для рендера письма — без привязки к ORM (проще тестировать)."""
conference_title: str
date_label: str
"""Дата встречи в `display_timezone`, формат `ДД.ММ.ГГГГ`."""
time_label: str
"""Время встречи в `display_timezone`, формат `ЧЧ:ММ–ЧЧ:ММ`."""
duration_minutes: int
participant_names: Sequence[str]
summary_text: str
def build_summary_email(context: SummaryEmailContext) -> tuple[str, str]:
"""Собрать (plaintext, html) тело письма с саммари встречи."""
return _build_plaintext(context), _build_html(context)
def _build_plaintext(context: SummaryEmailContext) -> str:
"""Простой текстовый вариант — без экранирования (не HTML)."""
participants = ", ".join(context.participant_names) or "участники не определены"
lines = [
f"Саммари встречи «{context.conference_title}»",
f"{context.date_label}, {context.time_label} ({context.duration_minutes} мин)",
"",
f"Участники: {participants}",
"",
context.summary_text.strip(),
"",
"",
"Письмо сформировано автоматически по итогам конференции в VidConf.",
]
return "\n".join(lines)
def _build_html(context: SummaryEmailContext) -> str:
"""HTML-вариант письма — все пользовательские подстановки экранированы."""
title = html.escape(context.conference_title)
date_label = html.escape(context.date_label)
time_label = html.escape(context.time_label)
participants = html.escape(", ".join(context.participant_names) or "не определены")
body_html = _render_summary_body(context.summary_text)
return f"""\
<!doctype html>
<html lang="ru">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head>
<body style="margin:0; padding:0; background-color:#F1F1F1; font-family:Helvetica, Arial, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#F1F1F1;">
<tr><td align="center" style="padding: 32px 16px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px; max-width:600px; background-color:#FFFFFF; border-radius:24px; overflow:hidden; border:1px solid #E1E3E9;">
<tr>
<td style="padding: 32px 32px 24px 32px; background-color:#F7F7F8;">
<p style="margin: 0 0 6px; font-family:Helvetica, Arial, sans-serif; font-size:12px; font-weight:bold; letter-spacing:.06em; text-transform:uppercase; color:#6976AC;">
Саммари встречи
</p>
<h1 style="margin:0 0 12px; font-family:Helvetica, Arial, sans-serif; font-size:24px; line-height:1.25; font-weight:bold; color:#2E3454;">
{title}
</h1>
<p style="margin:0; font-family:Helvetica, Arial, sans-serif; font-size:14px; color:#535F94;">
{date_label}, {time_label} &middot; {context.duration_minutes} мин
</p>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 8px 32px;">
<p style="margin:0 0 6px; font-family:Helvetica, Arial, sans-serif; font-size:12px; font-weight:bold; letter-spacing:.06em; text-transform:uppercase; color:#A6AECB;">
Участники
</p>
<p style="margin:0; font-family:Helvetica, Arial, sans-serif; font-size:14px; color:#2E3454;">
{participants}
</p>
</td>
</tr>
<tr><td style="padding: 16px 32px;"><hr style="border:none; border-top:1px solid #E1E3E9; margin:0;"></td></tr>
<tr>
<td style="padding: 8px 32px 24px 32px;">
{body_html}
</td>
</tr>
<tr>
<td style="padding: 20px 32px 32px 32px; background-color:#F7F7F8; border-top:1px solid #E1E3E9;">
<p style="margin:0; font-family:Helvetica, Arial, sans-serif; font-size:12px; line-height:1.6; color:#A6AECB;">
Письмо сформировано автоматически по итогам конференции в VidConf (self-hosted).
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>
"""
def _render_summary_body(summary_text: str) -> str:
"""Разобрать markdown-подобный текст саммари (`## заголовок`, `- пункт`) в HTML.
Формат фиксирован промптом суммаризации; при
отклонении LLM от формата непонятые строки рендерятся как обычные
абзацы — разбор не должен падать на неожиданном вводе. Всё содержимое
экранируется `html.escape` (текст саммари — от LLM, потенциальная
инъекция в HTML-письмо).
"""
parts: list[str] = []
in_list = False
for raw_line in summary_text.strip("\n").splitlines():
line = raw_line.strip()
if not line:
continue
if line.startswith("## "):
if in_list:
parts.append("</ul>")
in_list = False
heading = html.escape(line[3:].strip())
parts.append(
'<p style="margin:16px 0 8px; font-family:Helvetica, Arial, sans-serif; '
'font-size:15px; font-weight:bold; color:#2E3454;">' + heading + "</p>"
)
elif line.startswith("- "):
if not in_list:
parts.append('<ul style="margin:0 0 8px; padding-left:20px;">')
in_list = True
item = html.escape(line[2:].strip())
parts.append(
'<li style="font-family:Helvetica, Arial, sans-serif; font-size:14px; '
'line-height:1.6; color:#2E3454; padding:2px 0;">' + item + "</li>"
)
else:
if in_list:
parts.append("</ul>")
in_list = False
parts.append(
'<p style="margin:0 0 8px; font-family:Helvetica, Arial, sans-serif; '
'font-size:14px; line-height:1.6; color:#2E3454;">' + html.escape(line) + "</p>"
)
if in_list:
parts.append("</ul>")
return "\n ".join(parts)

148
backend/services/ics.py Normal file
View File

@@ -0,0 +1,148 @@
"""Генерация .ics-приглашений на конференцию (VEVENT, `METHOD:REQUEST`).
Разовая (плановая) конференция — `DTSTART`/`DTEND` в переданной таймзоне
отображения (`display_timezone` настройки инстанса);
закреплённая с повторением — `DTSTART` берётся из первого вхождения
`expand_occurrences` (см. `services/recurrence.py`) в таймзоне самого правила
повторения (`rule.timezone`), а рецидив описывается `RRULE`.
`UID` стабилен (`{conference.id}@vidconf`) — календарные клиенты обновляют уже
принятое приглашение по нему же, ориентируясь на растущий `SEQUENCE`
(`conference.ics_sequence`, инкрементируется при правке расписания —
`services/conferences.py::ConferenceService.update`).
"""
from datetime import UTC, datetime, timedelta
from typing import cast
from zoneinfo import ZoneInfo
from icalendar import Calendar, Event
from models.conference import Conference
from services.recurrence import RecurrenceRule, expand_occurrences
# Порядок ровно как в `date.weekday()`/`RecurrenceRule.weekdays` (0 — понедельник).
_WEEKDAY_CODES = ("MO", "TU", "WE", "TH", "FR", "SA", "SU")
# Длительность разового вхождения по умолчанию, если у конференции не указан
# `duration_minutes` (совпадает с `services.conferences.DEFAULT_OCCURRENCE_DURATION_MINUTES`,
# не импортируется напрямую — `ics.py` намеренно не зависит от `conferences.py`).
_DEFAULT_ONE_OFF_DURATION_MINUTES = 60
# Горизонт поиска первого вхождения повторяющейся серии от `anchor_date`
# (совпадает с `services.conferences.NEXT_OCCURRENCE_HORIZON`) — с запасом
# покрывает самый частый шаг повторения (weekly/monthly).
_OCCURRENCE_SEARCH_HORIZON = timedelta(days=400)
_PRODID = "-//VidConf//vidconf.example//RU"
class ConferenceHasNoScheduleError(ValueError):
"""У конференции нет ни `scheduled_at`, ни `recurrence` — приглашение строить не из чего."""
def build_invite(
conference: Conference,
*,
organizer_email: str | None,
join_url: str,
display_timezone: str,
) -> bytes:
"""Собрать .ics-приглашение (`METHOD:REQUEST`) на конференцию и вернуть его байты.
`display_timezone` — таймзона отображения разовых конференций (настройка
инстанса); для повторяющихся используется таймзона самого правила
(`RecurrenceRule.timezone`) — она обязательна в правиле и корректнее
отражает намерение организатора серии.
"""
if conference.recurrence is not None:
rule = RecurrenceRule.model_validate(conference.recurrence)
tz = ZoneInfo(rule.timezone)
dtstart = _first_occurrence_utc(rule).astimezone(tz)
dtend = dtstart + timedelta(minutes=rule.duration_minutes)
rrule_value: str | None = _build_rrule(rule)
elif conference.scheduled_at is not None:
tz = ZoneInfo(display_timezone)
dtstart = conference.scheduled_at.astimezone(tz)
duration = conference.duration_minutes or _DEFAULT_ONE_OFF_DURATION_MINUTES
dtend = dtstart + timedelta(minutes=duration)
rrule_value = None
else:
raise ConferenceHasNoScheduleError(
f"у конференции {conference.id} нет ни scheduled_at, ни recurrence"
)
# `Component.__init__` (общий предок `Event`/`Calendar`) не типизирован в
# `icalendar` — `no-untyped-call` здесь неизбежен без переписывания
# конструктора сторонней библиотеки.
event = Event() # type: ignore[no-untyped-call]
event.add("uid", f"{conference.id}@vidconf")
event.add("sequence", conference.ics_sequence)
event.add("dtstamp", datetime.now(UTC))
event.add("summary", conference.title or f"Конференция {conference.number}")
event.add(
"description",
f"Ссылка для входа: {join_url}\nНомер конференции: {conference.number}",
)
event.add("dtstart", dtstart)
event.add("dtend", dtend)
if organizer_email:
event.add("organizer", f"mailto:{organizer_email}")
if rrule_value is not None:
event.add("rrule", rrule_value)
calendar = Calendar() # type: ignore[no-untyped-call]
calendar.add("prodid", _PRODID)
calendar.add("version", "2.0")
calendar.add("method", "REQUEST")
calendar.add_component(event)
calendar.add_missing_timezones()
return cast(bytes, calendar.to_ical())
def _first_occurrence_utc(rule: RecurrenceRule) -> datetime:
"""Найти первое вхождение серии от `rule.anchor_date` (UTC, aware)."""
horizon = _OCCURRENCE_SEARCH_HORIZON
if rule.type == "every_n_days" and rule.interval_days is not None:
horizon = max(horizon, timedelta(days=rule.interval_days + 2))
t_from = rule.local_datetime(rule.anchor_date).astimezone(UTC)
occurrences = expand_occurrences(rule, t_from, t_from + horizon)
if not occurrences:
raise ConferenceHasNoScheduleError(
"правило повторения не даёт ни одного вхождения в горизонте поиска"
)
return occurrences[0]
def _build_rrule(rule: RecurrenceRule) -> str:
"""Собрать значение `RRULE` из `RecurrenceRule` («.ics»)."""
if rule.type == "weekly":
return f"FREQ=WEEKLY;BYDAY={_byday(rule.weekdays)}"
if rule.type == "biweekly":
return f"FREQ=WEEKLY;INTERVAL=2;BYDAY={_byday(rule.weekdays)};WKST=MO"
if rule.type == "monthly":
assert rule.day_of_month is not None # гарантировано валидацией RecurrenceRule
return f"FREQ=MONTHLY;BYMONTHDAY={_bymonthday(rule.day_of_month)}"
if rule.type == "every_n_days":
assert rule.interval_days is not None # гарантировано валидацией RecurrenceRule
return f"FREQ=DAILY;INTERVAL={rule.interval_days}"
raise AssertionError(f"неизвестный тип повторения: {rule.type}")
def _byday(weekdays: list[int]) -> str:
"""Список дней недели в порядок `BYDAY` (`MO,TU,...`)."""
return ",".join(_WEEKDAY_CODES[weekday] for weekday in sorted(weekdays))
def _bymonthday(day_of_month: int) -> int:
"""Смаппить `day_of_month` правила в `BYMONTHDAY` .ics.
`31` не встречается ни в одном месяце короче — маппится в `-1`
(последний день месяца), что СОВПАДАЕТ с клэмпом `expand_occurrences`.
`29`/`30` НЕ клэмпаются здесь: известное задокументированное расхождение
с поведением `expand_occurrences` — календарный
клиент в короткие месяцы (например, февраль) вхождение пропустит, тогда
как `expand_occurrences` использует клэмп к последнему дню месяца.
"""
return -1 if day_of_month == 31 else day_of_month

View File

@@ -0,0 +1,390 @@
"""Хранилище настроек инстанса (`instance_settings`, key-value JSONB) и их бутстрап.
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
`ai_level`, `summary_recipients`, `display_timezone`,
`registration_team_choice`, `registration_email_domain`) — новая настройка
не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
(например, при рестарте backend) не перетирает уже сделанные администратором
правки. Воркеры настройки только читают (`load_effective_config`); если
строк ещё нет (воркер стартовал раньше backend) — fallback на `plugins.yaml`
(т.к. воркеры в БД не пишут).
"""
import re
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import Settings
from core.plugins.config import (
AiLevel,
ChatConfig,
InstanceConfig,
PluginsConfig,
SummarizerConfig,
SummaryRecipientsMode,
TranscriberConfig,
load_plugins_config,
)
from models.instance_setting import InstanceSetting
from services.ai_levels import detect_ai_levels
from services.ai_tiers import TIERS
_KEY_TRANSCRIBER = "transcriber"
_KEY_SUMMARIZER = "summarizer"
_KEY_CHAT = "chat"
_KEY_AI_LEVEL = "ai_level"
_KEY_SUMMARY_RECIPIENTS = "summary_recipients"
_KEY_DISPLAY_TIMEZONE = "display_timezone"
_KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
_KEY_CHAT,
_KEY_TRANSCRIBER,
_KEY_SUMMARIZER,
_KEY_AI_LEVEL,
)
"""Ключи, которыми управляет матрица «пресет → настройки» инсталлятора —
переиспользуется
`scripts/apply_preset_settings.py`, чтобы не дублировать список строковых
имён ключей `instance_settings`."""
_DEFAULT_AI_LEVEL_VALUE = {"level": "min"}
_DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"}
_DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"}
_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE = {"enabled": False}
_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domain": None}
# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
# без пробелов — валидация после нормализации (strip, «@», lower).
_EMAIL_DOMAIN_PATTERN = re.compile(
r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$"
)
class SettingsUpdateIn(BaseModel):
"""Частичное обновление настроек инстанса — все поля опциональны (PUT-патч).
Используется и сервисным слоем (`InstanceSettingsService.update`), и
(реэкспортом) админ-API Блока C (`api/admin.py`) как тело запроса
`PUT /api/v1/admin/settings` — отдельная API-обёртка не нужна, схема
один в один совпадает с контрактом.
"""
chat_enabled: bool | None = None
transcription_enabled: bool | None = None
ai_level: AiLevel | None = None
summary_recipients: SummaryRecipientsMode | None = None
display_timezone: str | None = None
registration_team_choice: bool | None = None
registration_email_domain_enabled: bool | None = None
registration_email_domain: str | None = None
class BootstrapOverrides(BaseModel):
"""Переопределения дефолтов бутстрапа по пресету инсталлятора (`BOOTSTRAP_*` в `.env`).
Без них бутстрап `instance_settings` импортировал бы `plugins.yaml`,
где всё `enabled: true`, — независимо от выбранного пресета
поставки. Собирается `bootstrap_overrides_from_settings` и применяется
ПОВЕРХ дефолтов `plugins.yaml` перед `INSERT ... ON CONFLICT DO NOTHING`
(`ensure_bootstrapped`) — влияет только на чистую БД (первый запуск);
принудительное обновление уже существующих строк на живой инсталляции —
`scripts/apply_preset_settings.py`.
"""
chat_enabled: bool | None = None
# Единый переключатель «транскрибация+суммаризация» — как `transcription_enabled`
# в `SettingsUpdateIn`, управляет `transcriber.enabled` и `summarizer.enabled` вместе.
ai_enabled: bool | None = None
ai_level: AiLevel | None = None
def bootstrap_overrides_from_settings(settings: Settings) -> BootstrapOverrides:
"""Собрать `BootstrapOverrides` из `BOOTSTRAP_*` полей `core.config.Settings`."""
return BootstrapOverrides(
chat_enabled=settings.bootstrap_chat_enabled,
ai_enabled=settings.bootstrap_transcription_enabled,
ai_level=settings.bootstrap_ai_level,
)
def build_bootstrap_defaults(
plugins: PluginsConfig, overrides: BootstrapOverrides | None = None
) -> dict[str, dict[str, Any]]:
"""Собрать словарь дефолтов всех ключей `instance_settings` из `plugins.yaml`,
применив `overrides` пресета инсталлятора поверх (`chat`/`transcriber`+`summarizer`/`ai_level`).
Переиспользуется `ensure_bootstrapped` (чистая БД) и
`scripts/apply_preset_settings.py` (принудительное обновление живой БД).
"""
defaults: dict[str, dict[str, Any]] = {
_KEY_TRANSCRIBER: plugins.transcriber.model_dump(mode="json"),
_KEY_SUMMARIZER: plugins.summarizer.model_dump(mode="json"),
_KEY_CHAT: plugins.chat.model_dump(mode="json"),
_KEY_AI_LEVEL: dict(_DEFAULT_AI_LEVEL_VALUE),
_KEY_SUMMARY_RECIPIENTS: dict(_DEFAULT_SUMMARY_RECIPIENTS_VALUE),
_KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE),
_KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE),
_KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
}
if overrides is None:
return defaults
if overrides.chat_enabled is not None:
defaults[_KEY_CHAT] = {**defaults[_KEY_CHAT], "enabled": overrides.chat_enabled}
if overrides.ai_enabled is not None:
defaults[_KEY_TRANSCRIBER] = {
**defaults[_KEY_TRANSCRIBER],
"enabled": overrides.ai_enabled,
}
defaults[_KEY_SUMMARIZER] = {
**defaults[_KEY_SUMMARIZER],
"enabled": overrides.ai_enabled,
}
if overrides.ai_level is not None:
defaults[_KEY_AI_LEVEL] = {"level": overrides.ai_level}
return defaults
class InvalidAiLevelError(ValueError):
"""Запрошенный уровень AI недоступен (см. `services.ai_levels.detect_ai_levels`)."""
class InvalidTimezoneError(ValueError):
"""`display_timezone` не является валидным именем IANA-таймзоны."""
class InvalidEmailDomainError(ValueError):
"""Некорректная настройка верификации домена email при регистрации.
Поднимается при попытке включить верификацию без домена (`enabled=true`
и пустой/отсутствующий домен) либо при домене, не проходящем валидацию
простым паттерном доменного имени.
"""
class InstanceSettingsService:
"""CRUD-доступ к настройкам инстанса поверх таблицы `instance_settings`."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def ensure_bootstrapped(
self, yaml_path: str | Path, overrides: BootstrapOverrides | None = None
) -> None:
"""Импортировать дефолты `plugins.yaml` в `instance_settings` (однократно, идемпотентно).
`overrides` (матрица «пресет → настройки» инсталлятора, см.
`bootstrap_overrides_from_settings`) подменяет `chat.enabled`,
`transcriber.enabled`+`summarizer.enabled` и `ai_level` в дефолтах ДО
`INSERT ... ON CONFLICT DO NOTHING` — влияет только на строки, которых
ещё нет (чистая БД/первый запуск инсталлятора); уже существующие
строки (живая инсталляция, возможно с ручными правками администратора)
не трогает — `ON CONFLICT DO NOTHING` сохраняется как есть.
"""
plugins = load_plugins_config(yaml_path)
defaults = build_bootstrap_defaults(plugins, overrides)
for key, value in defaults.items():
stmt = (
pg_insert(InstanceSetting)
.values(key=key, value=value)
.on_conflict_do_nothing(index_elements=["key"])
)
await self._session.execute(stmt)
await self._session.commit()
async def get(self) -> InstanceConfig:
"""Собрать эффективную конфигурацию из текущих строк `instance_settings`."""
rows = await self._load_rows()
return _build_config(rows)
async def update(self, patch: SettingsUpdateIn) -> InstanceConfig:
"""Частично обновить настройки и вернуть новую эффективную конфигурацию.
`transcription_enabled` пишет `enabled` сразу в обе секции
(`transcriber`, `summarizer`) — это единый переключатель
«транскрибация+суммаризация».
"""
rows = await self._load_rows()
cfg = _build_config(rows)
if patch.ai_level is not None:
statuses = {status.level: status for status in detect_ai_levels(cfg)}
if not statuses[patch.ai_level].available:
raise InvalidAiLevelError(
f"уровень AI {patch.ai_level!r} недоступен: {statuses[patch.ai_level].reason}"
)
cfg.ai_level = patch.ai_level
await self._set(_KEY_AI_LEVEL, {"level": patch.ai_level})
if patch.display_timezone is not None:
_validate_timezone(patch.display_timezone)
cfg.display_timezone = patch.display_timezone
await self._set(_KEY_DISPLAY_TIMEZONE, {"tz": patch.display_timezone})
if patch.summary_recipients is not None:
cfg.summary_recipients = patch.summary_recipients
await self._set(_KEY_SUMMARY_RECIPIENTS, {"mode": patch.summary_recipients})
if patch.chat_enabled is not None:
cfg.chat = ChatConfig(enabled=patch.chat_enabled)
await self._set(_KEY_CHAT, cfg.chat.model_dump(mode="json"))
if patch.registration_team_choice is not None:
cfg.registration_team_choice = patch.registration_team_choice
await self._set(
_KEY_REGISTRATION_TEAM_CHOICE, {"enabled": patch.registration_team_choice}
)
if (
patch.registration_email_domain_enabled is not None
or patch.registration_email_domain is not None
):
enabled = (
patch.registration_email_domain_enabled
if patch.registration_email_domain_enabled is not None
else cfg.registration_email_domain_enabled
)
raw_domain = (
patch.registration_email_domain
if patch.registration_email_domain is not None
else cfg.registration_email_domain
)
domain = _normalize_email_domain(raw_domain) if raw_domain else None
if enabled and domain is None:
raise InvalidEmailDomainError(
"нельзя включить верификацию домена email без указания домена"
)
cfg.registration_email_domain_enabled = enabled
cfg.registration_email_domain = domain
await self._set(_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domain": domain})
if patch.transcription_enabled is not None:
cfg.transcriber = cfg.transcriber.model_copy(
update={"enabled": patch.transcription_enabled}
)
cfg.summarizer = cfg.summarizer.model_copy(
update={"enabled": patch.transcription_enabled}
)
await self._set(_KEY_TRANSCRIBER, cfg.transcriber.model_dump(mode="json"))
await self._set(_KEY_SUMMARIZER, cfg.summarizer.model_dump(mode="json"))
await self._session.commit()
return cfg
async def _load_rows(self) -> dict[str, Any]:
result = await self._session.execute(select(InstanceSetting))
return {row.key: row.value for row in result.scalars().all()}
async def _set(self, key: str, value: dict[str, Any]) -> None:
stmt = (
pg_insert(InstanceSetting)
.values(key=key, value=value)
.on_conflict_do_update(index_elements=["key"], set_={"value": value})
)
await self._session.execute(stmt)
async def load_effective_config(session: AsyncSession) -> InstanceConfig:
"""Загрузить эффективную конфигурацию для воркеров.
Если `instance_settings` ещё пуста (воркер стартовал раньше бутстрапа
backend) — fallback на `config/plugins.yaml` напрямую.
Воркеры в БД не пишут — конкурентной гонки с бутстрапом нет.
Уровни `medium`/`max` (ADR-004) перекрывают `transcriber`/
`summarizer` спекой `TIERS[ai_level]` — см. `_apply_tier_overrides`.
"""
result = await session.execute(select(InstanceSetting))
rows = {row.key: row.value for row in result.scalars().all()}
if not rows:
from core.config import get_settings
plugins = load_plugins_config(get_settings().plugins_config_path)
cfg = InstanceConfig(
transcriber=plugins.transcriber,
summarizer=plugins.summarizer,
chat=plugins.chat,
)
else:
cfg = _build_config(rows)
return _apply_tier_overrides(cfg)
def _apply_tier_overrides(cfg: InstanceConfig) -> InstanceConfig:
"""Подменить `transcriber`/`summarizer` спекой `TIERS[ai_level]` для `medium`/`max`.
`min` не переопределяется — использует дефолты `plugins.yaml`/правки
администратора как есть (обратная совместимость, ADR-004: min
остаётся конфигурируемым через существующий механизм). Флаг `enabled`
(переключатель «транскрибация+суммаризация» в админке) сохраняется из
текущей конфигурации — подмена per-tier затрагивает только
provider/model/options, не должна повторно включать отключённый модуль.
"""
if cfg.ai_level in ("medium", "max"):
spec = TIERS[cfg.ai_level]
cfg.transcriber = spec.transcriber.model_copy(update={"enabled": cfg.transcriber.enabled})
cfg.summarizer = spec.summarizer.model_copy(update={"enabled": cfg.summarizer.enabled})
return cfg
def _validate_timezone(tz: str) -> None:
"""Проверить, что `tz` — валидное имя IANA-таймзоны."""
try:
ZoneInfo(tz)
except ZoneInfoNotFoundError as exc:
raise InvalidTimezoneError(f"неизвестная таймзона: {tz!r}") from exc
def _normalize_email_domain(domain: str) -> str:
"""Нормализовать домен email (strip, убрать ведущую «@», lower) и провалидировать.
Валидация — простым паттерном доменного имени (минимум одна точка,
допустимые символы, без пробелов); иначе `InvalidEmailDomainError`.
"""
normalized = domain.strip()
if normalized.startswith("@"):
normalized = normalized[1:]
normalized = normalized.lower()
if not _EMAIL_DOMAIN_PATTERN.match(normalized):
raise InvalidEmailDomainError(f"некорректный домен email: {domain!r}")
return normalized
def _build_config(rows: dict[str, Any]) -> InstanceConfig:
"""Собрать `InstanceConfig` из строк `instance_settings` с фолбэком на дефолты моделей.
Отсутствие отдельного ключа (например, настройка добавлена уже после
бутстрапа существующего инстанса) не должно ронять чтение конфигурации —
используется дефолт соответствующей Pydantic-модели/константы.
"""
return InstanceConfig(
transcriber=TranscriberConfig.model_validate(rows.get(_KEY_TRANSCRIBER, {})),
summarizer=SummarizerConfig.model_validate(rows.get(_KEY_SUMMARIZER, {})),
chat=ChatConfig.model_validate(rows.get(_KEY_CHAT, {})),
ai_level=rows.get(_KEY_AI_LEVEL, _DEFAULT_AI_LEVEL_VALUE).get("level", "min"),
summary_recipients=rows.get(_KEY_SUMMARY_RECIPIENTS, _DEFAULT_SUMMARY_RECIPIENTS_VALUE).get(
"mode", "all"
),
display_timezone=rows.get(_KEY_DISPLAY_TIMEZONE, _DEFAULT_DISPLAY_TIMEZONE_VALUE).get(
"tz", "Europe/Moscow"
),
registration_team_choice=rows.get(
_KEY_REGISTRATION_TEAM_CHOICE, _DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE
).get("enabled", False),
registration_email_domain_enabled=rows.get(
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
).get("enabled", False),
registration_email_domain=rows.get(
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
).get("domain"),
)

View File

@@ -0,0 +1,40 @@
"""Постановка задачи `send_invitations` в очередь Celery.
Отправляет задачу по имени через голый Celery-клиент (`broker_url` без
`backend`) — тот же приём, что `services/pipeline_producer.py`: backend не
импортирует пакет `workers` (инвариант разделения слоёв, см. докстринг
`pipeline_producer.py`). Задача регистрируется и обрабатывается в
`workers/tasks/invitations.py` (блок C).
"""
import uuid
from celery import Celery
from core.config import get_settings
SEND_INVITATIONS_TASK_NAME = "workers.tasks.invitations.send_invitations"
NOTIFY_QUEUE = "notify"
def enqueue_invitations(conference_id: uuid.UUID, *, emails: list[str] | None = None) -> None:
"""Поставить в очередь `notify` рассылку .ics-приглашений на конференцию `conference_id`.
`emails=None` — получатели по умолчанию (владелец + для закреплённых
участники прошлых сеансов, см. `workers/tasks/invitations.py`); явный
список — ручная рассылка администратором. Задача сама идемпотентна
(журнал `email_deliveries(kind='invitation')` без unique, повтор
постановки максимум продублирует письмо).
Очередь передаётся явно (`queue=NOTIFY_QUEUE`): этот клиент — отдельный
экземпляр `Celery` без `task_routes` из `workers/celery_app.py` (тот
маршрут действует только внутри процесса воркера, который его
импортирует), поэтому без явного параметра задача ушла бы в дефолтную
очередь `celery` — тот же приём, что `TRANSCRIPTION_QUEUE` в
`pipeline_producer.py`.
"""
settings = get_settings()
client = Celery("vidconf-producer", broker=settings.redis_url)
client.send_task(
SEND_INVITATIONS_TASK_NAME, args=[str(conference_id), emails], queue=NOTIFY_QUEUE
)

View File

@@ -0,0 +1,43 @@
"""Выдача LiveKit access-токенов участникам конференции."""
from datetime import timedelta
from livekit import api
from core.config import get_settings
TOKEN_TTL = timedelta(hours=6)
def create_room_access_token(
*, room_name: str, identity: str, name: str, metadata: str | None = None
) -> str:
"""Создать JWT access-токен LiveKit для входа участника в комнату.
`identity` — произвольная строка identity, используемая webhook-
обработчиками (`services.webhook_handlers`) для сопоставления треков с
участником: `str(user_id)` для зарегистрированного пользователя,
`guest:{guest_access.id}` для гостя (ADR-001, п.6). `name` — отображаемое
имя, показывается клиентским UI. `metadata` — произвольная строка
(JSON), доступная клиенту как `participant.metadata` — используется
для аватара при выключенной камере; `None` (гость либо пользователь
без аватара) — метаданные не выставляются вовсе.
"""
settings = get_settings()
token = (
api.AccessToken(settings.livekit_api_key, settings.livekit_api_secret)
.with_identity(identity)
.with_name(name)
.with_grants(
api.VideoGrants(
room_join=True,
room=room_name,
can_publish=True,
can_subscribe=True,
)
)
.with_ttl(TOKEN_TTL)
)
if metadata is not None:
token = token.with_metadata(metadata)
return token.to_jwt()

View File

@@ -0,0 +1,70 @@
"""Постановка задачи `run_pipeline` в очередь Celery `transcription`.
Отправляет задачу по имени через голый Celery-клиент (`broker_url` без
`backend`) — backend не импортирует пакет `workers` (инвариант разделения
слоёв: API-процесс не должен тянуть зависимости воркеров, в т.ч. `faster-whisper`
через транзитивный импорт `workers.tasks.pipeline`). Задача регистрируется
и обрабатывается в `workers/tasks/pipeline.py` (блок C).
"""
import uuid
from celery import Celery
from kombu.exceptions import OperationalError
from core.config import get_settings
RUN_PIPELINE_TASK_NAME = "workers.tasks.pipeline.run_pipeline"
TRANSCRIPTION_QUEUE = "transcription"
def enqueue_pipeline(session_id: uuid.UUID) -> None:
"""Поставить в очередь `transcription` запуск AI-пайплайна для сеанса `session_id`.
Идемпотентно на стороне задачи (`run_pipeline` продолжает с последнего
успешного шага) — повторная постановка (например,
из `_on_room_finished` при повторной обработке) безопасна.
"""
settings = get_settings()
client = Celery("vidconf-producer", broker=settings.redis_url)
client.send_task(RUN_PIPELINE_TASK_NAME, args=[str(session_id)], queue=TRANSCRIPTION_QUEUE)
def transcription_queue_served(timeout: float = 1.0) -> bool:
"""Проверить, обслуживается ли очередь `transcription` хотя бы одним воркером Celery.
Детект доступности уровня
AI (`services.ai_levels.detect_ai_levels`) смотрит только на железо и
файлы моделей на диске, но не видит, запущен ли вообще воркер
транскрибации, — админка (`GET /admin/settings`) использует эту функцию,
чтобы предупредить «AI включён, но обработка недоступна».
`app.control.inspect(timeout=...).active_queues()` — блокирующий вызов
(ждёт ответа брокера/воркеров); возвращает `{hostname: [{"name": ...}, ...]}`
для ответивших воркеров либо `None`, если за `timeout` не ответил НИ ОДИН
(нет запущенных воркеров либо брокер Redis недоступен) — оба случая здесь
трактуются как «очередь не обслуживается». Вызывающая сторона (API-хендлер)
должна оборачивать в `anyio.to_thread.run_sync`, чтобы не блокировать event loop.
Полностью недоступный брокер (Redis лежит/не резолвится) — отдельный
случай: `kombu`/`redis-py` не возвращают `None`, а бросают исключение,
которое Celery оборачивает в `kombu.exceptions.OperationalError`
(«Recoverable message transport connection error» — проверено
эмпирически: недоступный/несуществующий хост даёт именно этот тип).
По контракту «нет воркеров ИЛИ брокер недоступен → `False`» это тоже
трактуется как «очередь не обслуживается», а не пробрасывается 500-кой
наружу в `GET`/`PUT /admin/settings`.
"""
settings = get_settings()
client = Celery("vidconf-producer", broker=settings.redis_url)
try:
active_queues = client.control.inspect(timeout=timeout).active_queues()
except OperationalError:
return False
if not active_queues:
return False
return any(
queue.get("name") == TRANSCRIPTION_QUEUE
for queues in active_queues.values()
for queue in queues
)

View File

@@ -0,0 +1,70 @@
"""Профиль пользователя: сборка ответа, правка данных, аватар.
Переиспользуется `api/users.py` (свой профиль) и `api/admin.py` (карточка
профиля любого пользователя администратором) — правила одни и те же (задача
4c: «те же данные, редактирование по тем же правилам»).
"""
import uuid
from pathlib import Path
from fastapi import UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from models.team import Team
from models.user import User
from repositories.admin import TeamRepository
from services.avatars import delete_avatar, read_and_validate_avatar, save_avatar
class TeamNotFoundError(Exception):
"""Команда с указанным `team_id` не найдена."""
async def resolve_team_name(session: AsyncSession, team_id: uuid.UUID | None) -> str | None:
"""Имя команды пользователя (`None`, если команда не привязана или была удалена)."""
if team_id is None:
return None
team = await session.get(Team, team_id)
return team.name if team is not None else None
async def update_profile_fields(
session: AsyncSession,
user: User,
*,
name_user: str | None,
team_id: uuid.UUID | None,
team_id_is_set: bool,
) -> User:
"""Изменить ФИО и/или команду пользователя.
`team_id_is_set` — поле присутствовало в теле запроса (в т.ч. явный
`null`, различаем через `model_fields_set` на стороне роутера) — тот же
приём, что и `summary_recipients`/`team_id` в других PATCH-эндпоинтах
(`services/conferences.py`, `api/admin.py`). Email НЕ принимается —
read-only поле профиля.
"""
if name_user is not None:
user.name_user = name_user
if team_id_is_set:
if team_id is not None and await TeamRepository(session).get(team_id) is None:
raise TeamNotFoundError
user.team_id = team_id
return user
async def set_avatar(media_root: Path, user: User, file: UploadFile) -> User:
"""Загрузить, провалидировать и сохранить новый аватар пользователя.
Бросает `AvatarTooLargeError`/`AvatarInvalidTypeError` (см. `services/avatars.py`).
"""
content, ext = await read_and_validate_avatar(file)
user.avatar_path = save_avatar(media_root, user.id, content, ext, old_path=user.avatar_path)
return user
def clear_avatar(media_root: Path, user: User) -> None:
"""Удалить файл аватара пользователя с диска и очистить `avatar_path`."""
delete_avatar(media_root, user.avatar_path)
user.avatar_path = None

View File

@@ -0,0 +1,165 @@
"""Recurrence-ядро для закреплённых конференций (ADR-001, п.3).
Собственная (не RRULE) модель повторения: ровно 4 типа, отображающихся на
форму UI 1:1. Правило хранит локальное время суток и IANA-таймзону —
чтобы корректно учитывать смещение UTC (переход на летнее/зимнее время в тех
зонах, где он есть); развёртка `expand_occurrences` всегда возвращает
timezone-aware метки в UTC (в БД — только UTC).
"""
import calendar
from datetime import UTC, date, datetime, time, timedelta
from typing import Literal, Self
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel, Field, field_validator, model_validator
RecurrenceType = Literal["weekly", "biweekly", "monthly", "every_n_days"]
class RecurrenceRule(BaseModel):
"""Правило повторения закреплённой конференции.
Поля, специфичные для типа (`weekdays` для weekly/biweekly,
`day_of_month` для monthly, `interval_days` для every_n_days),
валидируются в `_validate_type_specific_fields` — обязательность
зависит от `type`.
"""
type: RecurrenceType
weekdays: list[int] = Field(default_factory=list)
day_of_month: int | None = None
interval_days: int | None = None
anchor_date: date
time_local: str
timezone: str
duration_minutes: int
@field_validator("weekdays")
@classmethod
def _validate_weekdays(cls, value: list[int]) -> list[int]:
"""Дни недели — 0 (понедельник) .. 6 (воскресенье), как в `date.weekday()`."""
for weekday in value:
if not 0 <= weekday <= 6:
raise ValueError("weekday должен быть в диапазоне 0..6")
return value
@field_validator("day_of_month")
@classmethod
def _validate_day_of_month(cls, value: int | None) -> int | None:
if value is not None and not 1 <= value <= 31:
raise ValueError("day_of_month должен быть в диапазоне 1..31")
return value
@field_validator("interval_days")
@classmethod
def _validate_interval_days(cls, value: int | None) -> int | None:
if value is not None and value < 1:
raise ValueError("interval_days должен быть >= 1")
return value
@field_validator("time_local")
@classmethod
def _validate_time_local(cls, value: str) -> str:
try:
datetime.strptime(value, "%H:%M")
except ValueError as exc:
raise ValueError("time_local должен быть в формате HH:MM") from exc
return value
@field_validator("timezone")
@classmethod
def _validate_timezone(cls, value: str) -> str:
try:
ZoneInfo(value)
except (ZoneInfoNotFoundError, ValueError) as exc:
raise ValueError(f"неизвестная IANA-таймзона: {value}") from exc
return value
@model_validator(mode="after")
def _validate_type_specific_fields(self) -> Self:
"""Проверить, что поля, обязательные для конкретного `type`, заполнены."""
if self.type in ("weekly", "biweekly"):
if not self.weekdays:
raise ValueError(f"{self.type} требует непустой список weekdays")
elif self.type == "monthly":
if self.day_of_month is None:
raise ValueError("monthly требует day_of_month")
elif self.type == "every_n_days":
if self.interval_days is None:
raise ValueError("every_n_days требует interval_days")
return self
def time_of_day(self) -> time:
"""Разобрать `time_local` в объект `time`."""
hours, minutes = self.time_local.split(":")
return time(int(hours), int(minutes))
def local_datetime(self, on_date: date) -> datetime:
"""Собрать локальный aware datetime для конкретной календарной даты."""
return datetime.combine(on_date, self.time_of_day(), tzinfo=ZoneInfo(self.timezone))
def _clamped_day_of_month(year: int, month: int, day_of_month: int) -> int:
"""Клэмпнуть `day_of_month` к последнему дню месяца, если тот короче (например, 31 в апреле)."""
last_day = calendar.monthrange(year, month)[1]
return min(day_of_month, last_day)
def _week_start(on_date: date) -> date:
"""Начало недели (понедельник), содержащей `on_date`."""
return on_date - timedelta(days=on_date.weekday())
def _matches(rule: RecurrenceRule, on_date: date) -> bool:
"""Проверить, попадает ли календарная дата `on_date` в правило повторения."""
if rule.type == "weekly":
return on_date.weekday() in rule.weekdays
if rule.type == "biweekly":
if on_date.weekday() not in rule.weekdays:
return False
weeks_diff = (_week_start(on_date) - _week_start(rule.anchor_date)).days // 7
return weeks_diff % 2 == 0
if rule.type == "monthly":
assert rule.day_of_month is not None # гарантировано валидацией модели
return on_date.day == _clamped_day_of_month(on_date.year, on_date.month, rule.day_of_month)
if rule.type == "every_n_days":
assert rule.interval_days is not None # гарантировано валидацией модели
# anchor_date — первое вхождение серии: даты раньше него не считаются
# (иначе `%` на отрицательной разнице даст даты «до начала» серии).
if on_date < rule.anchor_date:
return False
return (on_date - rule.anchor_date).days % rule.interval_days == 0
raise AssertionError(f"неизвестный тип повторения: {rule.type}")
def expand_occurrences(rule: RecurrenceRule, t_from: datetime, t_to: datetime) -> list[datetime]:
"""Развернуть правило повторения в список моментов начала вхождений (UTC, aware).
Диапазон `[t_from, t_to]` включителен с обеих сторон. `t_from`/`t_to`
обязаны быть timezone-aware. При `t_from > t_to` возвращается пустой
список. Перебор идёт по календарным датам в локальной таймзоне правила
с суточным запасом с каждой стороны — компенсирует случаи, когда
смещение локальной зоны отличается от зоны границ диапазона настолько,
что вхождение попадает в диапазон, а его календарная дата — нет.
"""
if t_from.tzinfo is None or t_to.tzinfo is None:
raise ValueError("t_from и t_to должны быть timezone-aware")
if t_from > t_to:
return []
tz = ZoneInfo(rule.timezone)
start_date = (t_from.astimezone(tz) - timedelta(days=1)).date()
end_date = (t_to.astimezone(tz) + timedelta(days=1)).date()
occurrences: list[datetime] = []
current = start_date
while current <= end_date:
if _matches(rule, current):
candidate = rule.local_datetime(current).astimezone(UTC)
if t_from <= candidate <= t_to:
occurrences.append(candidate)
current += timedelta(days=1)
occurrences.sort()
return occurrences

View File

@@ -0,0 +1,357 @@
"""Бизнес-логика обработки webhook-событий LiveKit (ADR-001).
Дедупликация по `event.id` выполняется на уровне API-роутера
(`api/livekit_webhook.py`) в одной транзакции с эффектами обработчика —
этим обеспечивается идемпотентность пайплайна. Обработчики здесь
дополнительно используют get-or-create/guard-паттерны в репозитории
конференций, чтобы корректно восстанавливаться после пропущенных событий
(например, потерянного `room_started`).
Комната LiveKit больше не отдельная сущность — её имя всегда равно
`conferences.slug` (ADR-001, п.4), поэтому lookup идёт напрямую по slug.
"""
import logging
import uuid
from datetime import UTC, datetime
from livekit.protocol.egress import EgressStatus
from livekit.protocol.models import TrackSource, TrackType
from livekit.protocol.webhook import WebhookEvent
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from repositories.conferences import (
AudioTrackRepository,
ConferenceRepository,
ConferenceSessionRepository,
)
from services.egress import start_track_egress
from services.instance_settings import InstanceSettingsService
from services.pipeline_producer import enqueue_pipeline
logger = logging.getLogger(__name__)
# Префикс identity гостя в LiveKit-токене (ADR-001, п.6): `guest:{guest_access.id}`.
GUEST_IDENTITY_PREFIX = "guest:"
# Статусы EgressInfo, означающие неуспешное завершение записи (webhook `egress_ended`).
_EGRESS_FAILURE_STATUSES = frozenset(
{EgressStatus.EGRESS_FAILED, EgressStatus.EGRESS_ABORTED, EgressStatus.EGRESS_LIMIT_REACHED}
)
def _egress_ns_to_datetime(nanoseconds: int) -> datetime | None:
"""Перевести unix-наносекунды `EgressInfo.started_at`/`ended_at` в UTC datetime.
`0` (поле не проставлено) — валидное protobuf-значение по умолчанию, не
временная метка.
"""
if not nanoseconds:
return None
return datetime.fromtimestamp(nanoseconds / 1_000_000_000, tz=UTC)
class WebhookDispatcher:
"""Диспатчит `WebhookEvent` на обработчик по типу события."""
def __init__(self, session: AsyncSession) -> None:
self._conferences = ConferenceRepository(session)
self._sessions = ConferenceSessionRepository(session)
self._audio_tracks = AudioTrackRepository(session)
self._instance_settings = InstanceSettingsService(session)
async def dispatch(self, event: WebhookEvent) -> None:
"""Обработать одно webhook-событие; неизвестный тип события — no-op."""
handlers = {
"room_started": self._on_room_started,
"participant_joined": self._on_participant_joined,
"participant_left": self._on_participant_left,
"track_published": self._on_track_published,
"egress_ended": self._on_egress_ended,
"room_finished": self._on_room_finished,
}
handler = handlers.get(event.event)
if handler is None:
# Штатный шум: остальные типы событий (track_unpublished и т.п.) не нужны.
logger.debug("livekit webhook: необрабатываемый тип события %s", event.event)
return
await handler(event)
async def _on_room_started(self, event: WebhookEvent) -> None:
conference = await self._conferences.get_by_slug(event.room.name)
if conference is None:
logger.warning(
"livekit webhook room_started: конференция %s не найдена", event.room.name
)
return
conference.status = "active"
session_record = await self._sessions.get_or_create_open(
conference_id=conference.id, title=conference.title, t_start=datetime.now(UTC)
)
logger.info(
"livekit webhook room_started: конференция=%s сеанс=%s",
conference.id,
session_record.id,
)
async def _on_participant_joined(self, event: WebhookEvent) -> None:
conference = await self._conferences.get_by_slug(event.room.name)
if conference is None:
logger.warning(
"livekit webhook participant_joined: конференция %s не найдена", event.room.name
)
return
identity = _parse_identity(event.participant.identity)
if identity is None:
return
user_id, guest_id = identity
# Fallback на случай, если событие room_started было пропущено.
session_record = await self._sessions.get_or_create_open(
conference_id=conference.id, title=conference.title, t_start=datetime.now(UTC)
)
await self._sessions.add_participant(
session_id=session_record.id,
user_id=user_id,
guest_id=guest_id,
joined_at=datetime.now(UTC),
)
logger.info(
"livekit webhook participant_joined: конференция=%s identity=%s сеанс=%s",
conference.id,
event.participant.identity,
session_record.id,
)
async def _on_participant_left(self, event: WebhookEvent) -> None:
conference = await self._conferences.get_by_slug(event.room.name)
if conference is None:
logger.warning(
"livekit webhook participant_left: конференция %s не найдена", event.room.name
)
return
identity = _parse_identity(event.participant.identity)
if identity is None:
return
user_id, guest_id = identity
session_record = await self._sessions.get_open_by_conference(conference.id)
if session_record is None:
logger.warning(
"livekit webhook participant_left: нет открытого сеанса для конференции %s",
conference.id,
)
return
await self._sessions.mark_participant_left(
session_id=session_record.id,
user_id=user_id,
guest_id=guest_id,
left_at=datetime.now(UTC),
)
logger.info(
"livekit webhook participant_left: конференция=%s identity=%s сеанс=%s",
conference.id,
event.participant.identity,
session_record.id,
)
async def _on_track_published(self, event: WebhookEvent) -> None:
"""Запустить Track Egress для опубликованного аудиотрека микрофона (ADR-002).
Видео/скриншеринг и т.п. — no-op (диаризация не нужна: транскрибируем
только речь, трек = спикер). Идемпотентно: если строка трека уже
существует (гонка повторной доставки), egress повторно не запускается.
"""
if event.track.type != TrackType.AUDIO or event.track.source != TrackSource.MICROPHONE:
return
conference = await self._conferences.get_by_slug(event.room.name)
if conference is None:
logger.warning(
"livekit webhook track_published: конференция %s не найдена", event.room.name
)
return
session_record = await self._sessions.get_open_by_conference(conference.id)
if session_record is None:
logger.warning(
"livekit webhook track_published: нет открытого сеанса для конференции %s",
conference.id,
)
return
existing_track = await self._audio_tracks.get_by_session_and_track(
session_record.id, event.track.sid
)
if existing_track is not None:
logger.debug(
"livekit webhook track_published: трек %s уже записывается (сеанс=%s)",
event.track.sid,
session_record.id,
)
return
identity = _parse_identity(event.participant.identity)
if identity is None:
return
user_id, guest_id = identity
participant = await self._sessions.get_active_participant(
session_record.id, user_id=user_id, guest_id=guest_id
)
if participant is None:
logger.warning(
"livekit webhook track_published: нет активного участника identity=%s сеанса %s",
event.participant.identity,
session_record.id,
)
return
settings = get_settings()
filepath = (
f"{settings.recordings_dir}/{session_record.id}/{participant.id}_{event.track.sid}.ogg"
)
try:
result = await start_track_egress(event.room.name, event.track.sid, filepath)
except Exception as exc: # noqa: BLE001 — недоступность egress не должна ронять webhook
# Деплой-профиль (блок D): egress — необязательный сервис профиля
# `transcribe`; без него запись просто не стартует для этого трека
# (риск «Потерян webhook track_published»).
# Строку `session_audio_tracks` не создаём — у нас нет `egress_id`,
# по которому её мог бы финализировать `egress_ended`.
logger.warning(
"livekit webhook track_published: не удалось запустить egress для трека %s "
"сеанса %s: %s",
event.track.sid,
session_record.id,
exc,
)
return
await self._audio_tracks.create(
session_id=session_record.id,
participant_id=participant.id,
track_sid=event.track.sid,
egress_id=result.egress_id,
file_path=filepath,
started_at=result.started_at,
)
logger.info(
"livekit webhook track_published: сеанс=%s участник=%s трек=%s egress=%s",
session_record.id,
participant.id,
event.track.sid,
result.egress_id,
)
async def _on_egress_ended(self, event: WebhookEvent) -> None:
"""Финализировать строку аудиотрека по результату Track Egress.
Успех (`EGRESS_COMPLETE`) -> `status='recorded'`; ошибка (failed/
aborted/limit_reached) -> `status='failed'`. Отсутствие строки трека
(например, потерянный `track_published`) — предупреждение, не ошибка.
"""
egress_info = event.egress_info
status = "failed" if egress_info.status in _EGRESS_FAILURE_STATUSES else "recorded"
ended_at = _egress_ns_to_datetime(egress_info.ended_at) or datetime.now(UTC)
file_path = egress_info.file.filename or egress_info.file.location or None
record = await self._audio_tracks.finalize(
egress_id=egress_info.egress_id,
status=status,
ended_at=ended_at,
file_path=file_path,
)
if record is None:
logger.warning(
"livekit webhook egress_ended: строка трека для egress %s не найдена",
egress_info.egress_id,
)
return
logger.info(
"livekit webhook egress_ended: трек=%s egress=%s статус=%s",
record.id,
egress_info.egress_id,
status,
)
async def _on_room_finished(self, event: WebhookEvent) -> None:
conference = await self._conferences.get_by_slug(event.room.name)
if conference is None:
logger.warning(
"livekit webhook room_finished: конференция %s не найдена", event.room.name
)
return
session_record = await self._sessions.get_open_by_conference(conference.id)
if session_record is None:
logger.warning(
"livekit webhook room_finished: нет открытого сеанса для конференции %s",
conference.id,
)
return
now = datetime.now(UTC)
await self._sessions.close(session_record, t_end=now)
await self._sessions.close_all_open_participants(session_id=session_record.id, left_at=now)
# Незакреплённая умирает по завершении (история/саммари остаются);
# закреплённая возвращается в ожидание следующего вхождения (ADR-001, п.2).
if conference.is_pinned:
conference.status = "scheduled"
else:
conference.status = "ended"
conference.ended_at = now
logger.info(
"livekit webhook room_finished: конференция=%s сеанс=%s новый статус=%s",
conference.id,
session_record.id,
conference.status,
)
# Постановка AI-пайплайна: запись треков
# завершена (egress ещё может дописывать файлы — это ждёт шаг 3
# `run_pipeline`), сеанс закрыт — можно ставить задачу в очередь.
#
# Guard от зависания сеанса:
# если транскрибация выключена в настройках инстанса (пресеты 1/2
# инсталлятора — без AI, воркеров/LLM в деплое нет), задача `transcribe`
# уйдёт в очередь `transcription`, которую некому обслужить, и сеанс
# навсегда застрянет в `pipeline_status='recording'`. Вместо постановки
# в очередь сразу проставляем терминальный статус без AI-шагов —
# 'notified' (тот же статус, которым штатно завершается полный
# пайплайн; отдельный enum-статус/миграция не нужны).
cfg = await self._instance_settings.get()
if cfg.transcriber.enabled:
enqueue_pipeline(session_record.id)
else:
session_record.pipeline_status = "notified"
logger.info(
"livekit webhook room_finished: транскрибация выключена — "
"сеанс=%s сразу переведён в pipeline_status='notified'",
session_record.id,
)
def _parse_identity(identity: str) -> tuple[uuid.UUID | None, uuid.UUID | None] | None:
"""Распарсить identity участника в пару (`user_id`, `guest_id`) — ровно один заполнен.
`guest:{guest_access.id}` — гость; иначе — `str(user.id)` зарегистрированного
пользователя. Невалидный/пустой identity — предупреждение в лог и пропуск
события (не должно приводить к 500).
"""
try:
if identity.startswith(GUEST_IDENTITY_PREFIX):
guest_id = uuid.UUID(identity.removeprefix(GUEST_IDENTITY_PREFIX))
return None, guest_id
return uuid.UUID(identity), None
except (ValueError, AttributeError):
logger.warning("livekit webhook: невалидный identity участника %r", identity)
return None

View File

233
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,233 @@
"""Общие fixtures для интеграционных тестов, запускаемых против реального экземпляра Postgres
из `deploy/docker-compose.yml`.
Каждый тест запускается внутри внешней транзакции, которая никогда не коммитится; ORM
`AsyncSession` присоединяется к ней с помощью `join_transaction_mode="create_savepoint"` так,
что даже вызовы `session.commit()` (которые вызывают IntegrityError при нарушении
constraint) влияют только на savepoint и полностью отменяются внешним rollback в teardown.
"""
import asyncio
import json
from collections.abc import AsyncGenerator
from typing import Any, cast
import httpx
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession
from starlette.types import Message, Scope
from core.db import engine, get_session
from core.redis import redis_client
from main import create_app
from models.instance_setting import InstanceSetting
@pytest_asyncio.fixture(autouse=True)
async def _reset_rate_limits() -> AsyncGenerator[None, None]:
"""Сбросить счётчики rate limit (Redis реальный, общий на все тесты) до и после теста.
Без этого параллельные/последовательные тесты публичных эндпоинтов
(`resolve`, `guest-join`) делили бы один и тот же счётчик по IP тестового
клиента и мешали друг другу (см. `core/rate_limit.py`).
"""
await _delete_rate_limit_keys()
yield
await _delete_rate_limit_keys()
async def _delete_rate_limit_keys() -> None:
keys = [key async for key in redis_client.scan_iter(match="rate_limit:*")]
if keys:
await redis_client.delete(*keys)
@pytest_asyncio.fixture(autouse=True)
async def _preserve_instance_settings() -> AsyncGenerator[None, None]:
"""Гарантировать, что тест не оставляет следов в `instance_settings` общей dev-БД.
Строки `instance_settings` — живые настройки dev-инстанса (тоггл чата,
домен регистрации и т.п.), а не тестовые данные: закоммиченная тестом
правка молча меняет поведение dev-окружения (реально воспроизводилось —
после прогонов тестов чат в dev-инстансе оказался выключен).
Снимок закоммиченного состояния снимается отдельным подключением (мимо
savepoint-транзакции теста, см. докстринг модуля), после теста таблица
приводится к снимку: появившиеся ключи удаляются, изменённые и пропавшие —
восстанавливаются. Для корректных savepoint-тестов это no-op ценой одного
SELECT — страховка на случай любой записи мимо savepoint-сессии.
"""
before = await _load_committed_instance_settings()
yield
after = await _load_committed_instance_settings()
if after == before:
return
async with engine.connect() as connection:
extra_keys = after.keys() - before.keys()
if extra_keys:
await connection.execute(
delete(InstanceSetting).where(InstanceSetting.key.in_(extra_keys))
)
for key, value in before.items():
if after.get(key) != value:
await connection.execute(
pg_insert(InstanceSetting)
.values(key=key, value=value)
.on_conflict_do_update(index_elements=["key"], set_={"value": value})
)
await connection.commit()
async def _load_committed_instance_settings() -> dict[str, Any]:
"""Прочитать закоммиченные строки `instance_settings` отдельным подключением."""
async with engine.connect() as connection:
result = await connection.execute(select(InstanceSetting.key, InstanceSetting.value))
return {key: value for key, value in result.all()}
@pytest_asyncio.fixture
async def db_connection() -> AsyncGenerator[AsyncConnection, None]:
async with engine.connect() as connection:
trans = await connection.begin()
try:
yield connection
finally:
await trans.rollback()
@pytest_asyncio.fixture
async def db_session(db_connection: AsyncConnection) -> AsyncGenerator[AsyncSession, None]:
session = AsyncSession(
bind=db_connection,
join_transaction_mode="create_savepoint",
expire_on_commit=False,
)
try:
yield session
finally:
await session.close()
@pytest_asyncio.fixture
async def app(db_session: AsyncSession) -> AsyncGenerator[FastAPI, None]:
"""Экземпляр FastAPI-приложения с `get_session`, подменённым на тестовую (savepoint) сессию."""
application = create_app()
async def _override_get_session() -> AsyncGenerator[AsyncSession, None]:
yield db_session
application.dependency_overrides[get_session] = _override_get_session
yield application
@pytest_asyncio.fixture
async def client(app: FastAPI) -> AsyncGenerator[httpx.AsyncClient, None]:
"""Асинхронный HTTP-клиент поверх приложения.
База `https://test` (а не `http://`) нужна, чтобы httpx сохранял в своём
cookie jar httpOnly Secure cookie с refresh-токеном между запросами.
"""
transport = ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as ac:
yield ac
# Таймаут ожидания ответа приложения в WS-тестах — на порядок больше
# `AUTH_TIMEOUT_SECONDS` эндпоинта не нужен, реальный ответ приходит мгновенно.
_RECEIVE_TIMEOUT_S = 5.0
class ASGIWebSocketSession:
"""Минимальный in-process ASGI websocket-клиент для тестов.
`httpx.AsyncClient`/`ASGITransport` не поддерживают websocket-соединения,
а `starlette.testclient.TestClient` гоняет ASGI-приложение в отдельном
потоке со своим event loop — тестовая `db_session` (см. выше) привязана к
`AsyncConnection` ТЕКУЩЕГО loop, и обращение к ней из другого loop роняет
asyncpg `RuntimeError: Future attached to a different loop`. Этот класс
драйвит `app(scope, receive, send)` напрямую в текущем event loop через
пару `asyncio.Queue`, эмулируя протокол ASGI websocket (см.
`starlette/websockets.py`).
"""
def __init__(self, app: FastAPI, path: str) -> None:
self._to_app: asyncio.Queue[Message] = asyncio.Queue()
self._from_app: asyncio.Queue[Message] = asyncio.Queue()
scope: Scope = {
"type": "websocket",
"asgi": {"version": "3.0", "spec_version": "2.3"},
"http_version": "1.1",
"scheme": "ws",
"path": path,
"raw_path": path.encode(),
"query_string": b"",
"headers": [],
"client": ("test-client", 12345),
"server": ("test-server", 80),
"subprotocols": [],
}
self._task = asyncio.create_task(app(scope, self._receive, self._send))
async def _receive(self) -> Message:
return await self._to_app.get()
async def _send(self, message: Message) -> None:
await self._from_app.put(message)
async def connect(self) -> Message:
"""Отправить `websocket.connect` и дождаться ответа (`accept`/`close`)."""
await self._to_app.put({"type": "websocket.connect"})
return await self._receive_from_app()
async def send_json(self, data: dict[str, Any]) -> None:
await self._to_app.put({"type": "websocket.receive", "text": json.dumps(data)})
async def receive_json(self) -> dict[str, Any]:
message = await self._receive_from_app()
assert message["type"] == "websocket.send", message
return cast(dict[str, Any], json.loads(message["text"]))
async def receive_close(self) -> int:
message = await self._receive_from_app()
assert message["type"] == "websocket.close", message
return int(message["code"])
async def _receive_from_app(self) -> Message:
# Фиксированный таймаут ожидания ответа приложения — тестовый
# хелпер не даёт вызывающей стороне переопределить его параметром
# (см. ASYNC109: явный параметр `timeout` у async-функции — плохая
# практика, вместо этого таймаут задаётся здесь одним местом).
async with asyncio.timeout(_RECEIVE_TIMEOUT_S):
return await self._from_app.get()
async def aclose(self) -> None:
"""Эмулировать разрыв соединения клиентом и дождаться завершения ASGI-приложения."""
if not self._task.done():
await self._to_app.put({"type": "websocket.disconnect", "code": 1000})
try:
await asyncio.wait_for(self._task, timeout=5.0)
except Exception: # noqa: BLE001 — best-effort teardown в тестах
self._task.cancel()
@pytest_asyncio.fixture
async def ws_client(app: FastAPI) -> AsyncGenerator[Any, None]:
"""Фабрика in-process WS-клиентов (`ASGIWebSocketSession`) поверх текущего `app`.
Возвращает callable `(path) -> ASGIWebSocketSession`; открытые сессии
закрываются автоматически по завершении теста.
"""
sessions: list[ASGIWebSocketSession] = []
def _factory(path: str) -> ASGIWebSocketSession:
session = ASGIWebSocketSession(app, path)
sessions.append(session)
return session
yield _factory
for session in sessions:
await session.aclose()

View File

@@ -0,0 +1,16 @@
{
"event": "egress_ended",
"id": "{event_id}",
"createdAt": "1720000004",
"egressInfo": {
"egressId": "{egress_id}",
"roomName": "{room_name}",
"status": "EGRESS_COMPLETE",
"startedAt": "1720000002000000000",
"endedAt": "1720000004000000000",
"file": {
"filename": "{file_path}",
"location": "{file_path}"
}
}
}

View File

@@ -0,0 +1,13 @@
{
"event": "egress_ended",
"id": "{event_id}",
"createdAt": "1720000004",
"egressInfo": {
"egressId": "{egress_id}",
"roomName": "{room_name}",
"status": "EGRESS_FAILED",
"startedAt": "1720000002000000000",
"endedAt": "1720000004000000000",
"error": "pipeline failure"
}
}

Some files were not shown because too many files have changed in this diff Show More