Первоначальная версия VidConf
This commit is contained in:
1
backend/.python-version
Normal file
1
backend/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12
|
||||
40
backend/Dockerfile
Normal file
40
backend/Dockerfile
Normal 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
356
backend/README.md
Normal 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
149
backend/alembic.ini
Normal 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
1
backend/alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration with an async dbapi.
|
||||
93
backend/alembic/env.py
Normal file
93
backend/alembic/env.py
Normal 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()
|
||||
28
backend/alembic/script.py.mako
Normal file
28
backend/alembic/script.py.mako
Normal 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"}
|
||||
@@ -0,0 +1,51 @@
|
||||
"""auth email verification and livekit webhook events
|
||||
|
||||
Revision ID: 149d70424ae0
|
||||
Revises: 1e2e34a0cb06
|
||||
Create Date: 2026-07-15 03:54:52.548246
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '149d70424ae0'
|
||||
down_revision: Union[str, Sequence[str], None] = '1e2e34a0cb06'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('livekit_webhook_events',
|
||||
sa.Column('event_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('event_type', sa.String(length=64), nullable=False),
|
||||
sa.Column('received_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('event_id')
|
||||
)
|
||||
op.create_table('email_verification_tokens',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('token_hash', sa.Text(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('token_hash')
|
||||
)
|
||||
op.create_index('ix_email_verification_tokens_user_id', 'email_verification_tokens', ['user_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index('ix_email_verification_tokens_user_id', table_name='email_verification_tokens')
|
||||
op.drop_table('email_verification_tokens')
|
||||
op.drop_table('livekit_webhook_events')
|
||||
# ### end Alembic commands ###
|
||||
136
backend/alembic/versions/1e2e34a0cb06_initial_schema.py
Normal file
136
backend/alembic/versions/1e2e34a0cb06_initial_schema.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 1e2e34a0cb06
|
||||
Revises:
|
||||
Create Date: 2026-07-15 01:48:41.232692
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '1e2e34a0cb06'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# Required for the `room_bookings` EXCLUDE USING gist constraint, which
|
||||
# mixes an equality operator (room_id) with a range overlap operator
|
||||
# (period) — btree_gist supplies the GiST operator class for `=` on
|
||||
# non-range types such as uuid.
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS btree_gist")
|
||||
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('rooms',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('is_pinned', sa.Boolean(), server_default='false', nullable=False),
|
||||
sa.Column('permanent_link', sa.String(length=64), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('permanent_link')
|
||||
)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('name_user', sa.String(length=255), nullable=False),
|
||||
sa.Column('password_hash', sa.Text(), nullable=False),
|
||||
sa.Column('role', sa.String(length=16), server_default='user', nullable=False),
|
||||
sa.Column('email_verified', sa.Boolean(), server_default='false', nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.CheckConstraint("role IN ('admin', 'user')", name='ck_users_role'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('email')
|
||||
)
|
||||
op.create_table('room_bookings',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('room_id', sa.UUID(), nullable=False),
|
||||
sa.Column('organizer_id', sa.UUID(), nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=True),
|
||||
sa.Column('period', postgresql.TSTZRANGE(), nullable=False),
|
||||
sa.Column('is_closed', sa.Boolean(), server_default='false', nullable=False),
|
||||
sa.Column('password_hash', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
postgresql.ExcludeConstraint((sa.column('room_id'), '='), (sa.column('period'), '&&'), using='gist', name='excl_room_bookings_overlap'),
|
||||
sa.CheckConstraint('NOT isempty(period)', name='ck_room_bookings_period_not_empty'),
|
||||
sa.ForeignKeyConstraint(['organizer_id'], ['users.id'], ondelete='RESTRICT'),
|
||||
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('conferences',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('room_id', sa.UUID(), nullable=False),
|
||||
sa.Column('booking_id', sa.UUID(), nullable=True),
|
||||
sa.Column('title', sa.String(length=255), nullable=True),
|
||||
sa.Column('t_start', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('t_end', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('summary_data', sa.Text(), nullable=True),
|
||||
sa.Column('pipeline_status', sa.Enum('recording', 'transcribing', 'summarizing', 'notified', 'failed', name='pipeline_status'), server_default='recording', nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['booking_id'], ['room_bookings.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_conferences_pipeline_status', 'conferences', ['pipeline_status'], unique=False)
|
||||
op.create_index('ix_conferences_room_id_t_start', 'conferences', ['room_id', 't_start'], unique=False)
|
||||
op.create_table('chat_messages',
|
||||
sa.Column('id', sa.BigInteger(), sa.Identity(always=True), nullable=False),
|
||||
sa.Column('conference_id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('text', sa.Text(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['conference_id'], ['conferences.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_chat_messages_conference_id_created_at', 'chat_messages', ['conference_id', 'created_at'], unique=False)
|
||||
op.create_table('conference_participants',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('conference_id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('joined_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('left_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['conference_id'], ['conferences.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_conference_participants_conference_id', 'conference_participants', ['conference_id'], unique=False)
|
||||
op.create_table('phrases',
|
||||
sa.Column('id', sa.BigInteger(), sa.Identity(always=True), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('conference_id', sa.UUID(), nullable=False),
|
||||
sa.Column('data', sa.Text(), nullable=False),
|
||||
sa.Column('t_start', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('t_end', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['conference_id'], ['conferences.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_phrases_conference_id_t_start', 'phrases', ['conference_id', 't_start'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index('ix_phrases_conference_id_t_start', table_name='phrases')
|
||||
op.drop_table('phrases')
|
||||
op.drop_index('ix_conference_participants_conference_id', table_name='conference_participants')
|
||||
op.drop_table('conference_participants')
|
||||
op.drop_index('ix_chat_messages_conference_id_created_at', table_name='chat_messages')
|
||||
op.drop_table('chat_messages')
|
||||
op.drop_index('ix_conferences_room_id_t_start', table_name='conferences')
|
||||
op.drop_index('ix_conferences_pipeline_status', table_name='conferences')
|
||||
op.drop_table('conferences')
|
||||
op.drop_table('room_bookings')
|
||||
op.drop_table('users')
|
||||
op.drop_table('rooms')
|
||||
# ### end Alembic commands ###
|
||||
postgresql.ENUM(name="pipeline_status").drop(op.get_bind(), checkfirst=True)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""booking access link and participants
|
||||
|
||||
Revision ID: 299053c6f7b8
|
||||
Revises: 149d70424ae0
|
||||
Create Date: 2026-07-15 16:52:55.554576
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '299053c6f7b8'
|
||||
down_revision: Union[str, Sequence[str], None] = '149d70424ae0'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table(
|
||||
'booking_participants',
|
||||
sa.Column('booking_id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['booking_id'], ['room_bookings.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('booking_id', 'user_id'),
|
||||
)
|
||||
|
||||
# Колонка добавляется nullable, чтобы не упасть на уже существующих
|
||||
# строках; backfill генерирует уникальный slug каждой существующей
|
||||
# брони, после чего ограничение NOT NULL накладывается отдельным шагом.
|
||||
op.add_column('room_bookings', sa.Column('access_link', sa.String(length=43), nullable=True))
|
||||
# gen_random_bytes живёт в pgcrypto (в отличие от gen_random_uuid,
|
||||
# встроенного в ядро с PG13) — расширение включается здесь же.
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto")
|
||||
op.execute("UPDATE room_bookings SET access_link = encode(gen_random_bytes(16), 'hex')")
|
||||
op.alter_column('room_bookings', 'access_link', nullable=False)
|
||||
op.create_unique_constraint(
|
||||
'uq_room_bookings_access_link', 'room_bookings', ['access_link']
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_constraint('uq_room_bookings_access_link', 'room_bookings', type_='unique')
|
||||
op.drop_column('room_bookings', 'access_link')
|
||||
op.drop_table('booking_participants')
|
||||
69
backend/alembic/versions/504791847d4f_chat_guest_authors.py
Normal file
69
backend/alembic/versions/504791847d4f_chat_guest_authors.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""chat guest authors
|
||||
|
||||
Разрешить сообщения чата (`chat_messages`) от гостей и хранить снапшот имени
|
||||
автора:
|
||||
- `user_id` становится nullable — автором может быть гость;
|
||||
- `guest_access_id` — необязательная ссылка на `guest_access`, `ON DELETE
|
||||
CASCADE` (удаление гостевой записи удаляет и его сообщения чата);
|
||||
- `author_name` — снапшот отображаемого имени из LiveKit-токена на момент
|
||||
отправки; добавляется nullable, backfill из `users.name_user` для уже
|
||||
существующих строк (все они с `user_id`, т.к. гостевого автора раньше не
|
||||
было), затем ужесточается до `NOT NULL`;
|
||||
- `ck_chat_messages_author` — ровно один из `user_id`/`guest_access_id`
|
||||
обязателен (как у `ConferenceParticipant`, ADR-001, п.6).
|
||||
|
||||
Revision ID: 504791847d4f
|
||||
Revises: 9d37822e4513
|
||||
Create Date: 2026-07-18 18:45:20.921653
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '504791847d4f'
|
||||
down_revision: Union[str, Sequence[str], None] = '9d37822e4513'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.add_column('chat_messages', sa.Column('guest_access_id', sa.UUID(), nullable=True))
|
||||
op.add_column('chat_messages', sa.Column('author_name', sa.String(length=255), nullable=True))
|
||||
op.alter_column('chat_messages', 'user_id', existing_type=sa.UUID(), nullable=True)
|
||||
|
||||
op.create_foreign_key(
|
||||
'fk_chat_messages_guest_access_id_guest_access',
|
||||
'chat_messages', 'guest_access', ['guest_access_id'], ['id'], ondelete='CASCADE',
|
||||
)
|
||||
|
||||
# Backfill: до этой миграции автор всегда был зарегистрированным
|
||||
# пользователем — берём снапшот его текущего имени.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE chat_messages cm
|
||||
SET author_name = u.name_user
|
||||
FROM users u
|
||||
WHERE cm.user_id = u.id
|
||||
"""
|
||||
)
|
||||
op.alter_column('chat_messages', 'author_name', existing_type=sa.String(length=255), nullable=False)
|
||||
|
||||
op.create_check_constraint(
|
||||
'ck_chat_messages_author',
|
||||
'chat_messages',
|
||||
'user_id IS NOT NULL OR guest_access_id IS NOT NULL',
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_constraint('ck_chat_messages_author', 'chat_messages', type_='check')
|
||||
op.drop_constraint('fk_chat_messages_guest_access_id_guest_access', 'chat_messages', type_='foreignkey')
|
||||
op.alter_column('chat_messages', 'user_id', existing_type=sa.UUID(), nullable=False)
|
||||
op.drop_column('chat_messages', 'author_name')
|
||||
op.drop_column('chat_messages', 'guest_access_id')
|
||||
108
backend/alembic/versions/5970bf64fc43_settings_notifications.py
Normal file
108
backend/alembic/versions/5970bf64fc43_settings_notifications.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""settings and notifications
|
||||
|
||||
Настройки инстанса и журнал почтовых рассылок:
|
||||
- `instance_settings` — key-value настройки инстанса (JSONB), бутстрап из
|
||||
`config/plugins.yaml` в lifespan backend (`services/instance_settings.py`);
|
||||
- `email_deliveries` — идемпотентность рассылки саммари (уникальный частичный
|
||||
индекс по `(session_id, recipient_email)` при `kind='summary'`) и журнал
|
||||
приглашений (`kind='invitation'`, без unique — переслать обновление
|
||||
расписания обязано дублировать письмо);
|
||||
- `conferences.summary_recipients` — переопределение рассылки для конкретной
|
||||
конференции (`NULL` = дефолт инстанса), `conferences.ics_sequence` —
|
||||
счётчик изменений расписания для VEVENT `SEQUENCE`;
|
||||
- `users.is_blocked` — блокировка администратором, проверяется немедленно в
|
||||
`api/deps.py::_user_from_token`.
|
||||
|
||||
Revision ID: 5970bf64fc43
|
||||
Revises: 88aa676ac140
|
||||
Create Date: 2026-07-17 23:24:46.699304
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '5970bf64fc43'
|
||||
down_revision: Union[str, Sequence[str], None] = '88aa676ac140'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table(
|
||||
'instance_settings',
|
||||
sa.Column('key', sa.String(), nullable=False),
|
||||
sa.Column('value', postgresql.JSONB(), nullable=False),
|
||||
sa.Column(
|
||||
'updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint('key'),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'email_deliveries',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('session_id', sa.UUID(), nullable=True),
|
||||
sa.Column('conference_id', sa.UUID(), nullable=True),
|
||||
sa.Column('recipient_email', sa.String(length=320), nullable=False),
|
||||
sa.Column('kind', sa.String(length=16), nullable=False),
|
||||
sa.Column(
|
||||
'sent_at', sa.DateTime(timezone=True), server_default=sa.text('now()'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint("kind IN ('summary', 'invitation')", name='ck_email_deliveries_kind'),
|
||||
sa.CheckConstraint(
|
||||
'(kind = \'summary\') = (session_id IS NOT NULL)',
|
||||
name='ck_email_deliveries_summary_has_session',
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
'(kind = \'invitation\') = (conference_id IS NOT NULL)',
|
||||
name='ck_email_deliveries_invitation_has_conference',
|
||||
),
|
||||
sa.ForeignKeyConstraint(['session_id'], ['conference_sessions.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['conference_id'], ['conferences.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index(
|
||||
'uq_email_deliveries_summary', 'email_deliveries', ['session_id', 'recipient_email'],
|
||||
unique=True, postgresql_where=sa.text("kind = 'summary'"),
|
||||
)
|
||||
op.create_index(
|
||||
'ix_email_deliveries_conference', 'email_deliveries', ['conference_id'],
|
||||
)
|
||||
|
||||
op.add_column('conferences', sa.Column('summary_recipients', sa.String(length=16), nullable=True))
|
||||
op.add_column(
|
||||
'conferences',
|
||||
sa.Column('ics_sequence', sa.Integer(), server_default='0', nullable=False),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
'ck_conferences_summary_recipients',
|
||||
'conferences',
|
||||
"summary_recipients IN ('all', 'owner')",
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
'users', sa.Column('is_blocked', sa.Boolean(), server_default='false', nullable=False)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_column('users', 'is_blocked')
|
||||
|
||||
op.drop_constraint('ck_conferences_summary_recipients', 'conferences', type_='check')
|
||||
op.drop_column('conferences', 'ics_sequence')
|
||||
op.drop_column('conferences', 'summary_recipients')
|
||||
|
||||
op.drop_index('ix_email_deliveries_conference', table_name='email_deliveries')
|
||||
op.drop_index('uq_email_deliveries_summary', table_name='email_deliveries')
|
||||
op.drop_table('email_deliveries')
|
||||
|
||||
op.drop_table('instance_settings')
|
||||
@@ -0,0 +1,109 @@
|
||||
"""session_audio_tracks + атрибуция phrases к участнику (ADR-002)
|
||||
|
||||
Схема БД для записи аудиодорожек сеанса и атрибуции фраз:
|
||||
- новая таблица `session_audio_tracks` — одна аудиодорожка сеанса, записанная
|
||||
LiveKit Track Egress (per-track, трек = спикер, диаризация не нужна);
|
||||
- `phrases`: `user_id` -> `participant_id` (FK `conference_participants.id`,
|
||||
ON DELETE CASCADE) — атрибуция фразы к окну присутствия участника сеанса
|
||||
(пользователя ИЛИ гостя), а не напрямую к `users`
|
||||
(`docs/architecture/adr/002-phrase-attribution-session-participant.md`).
|
||||
|
||||
Продакшен-данных нет (см. ADR-002, контекст) — простая замена колонки без
|
||||
backfill; единственная строка `phrases`, оставшаяся в дев-БД от ручного
|
||||
тестирования, удаляется явно (см. `_clear_dev_phrases`), т.к. её
|
||||
`user_id` не сопоставим ни с одним `conference_participants.id`. Downgrade
|
||||
симметричен и данные `phrases` не восстанавливает (как и в f418dd65e7b1).
|
||||
|
||||
Revision ID: 88aa676ac140
|
||||
Revises: f418dd65e7b1
|
||||
Create Date: 2026-07-17 10:30:10.456704
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '88aa676ac140'
|
||||
down_revision: Union[str, Sequence[str], None] = 'f418dd65e7b1'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _clear_dev_phrases(connection: Connection) -> None:
|
||||
"""Удалить строки `phrases`, оставшиеся от ручного тестирования до ADR-002.
|
||||
|
||||
Продакшен-данных нет (см. докстринг ревизии) — на пустой таблице это no-op.
|
||||
"""
|
||||
connection.execute(sa.text("DELETE FROM phrases"))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
bind = op.get_bind()
|
||||
|
||||
op.create_table(
|
||||
'session_audio_tracks',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('session_id', sa.UUID(), nullable=False),
|
||||
sa.Column('participant_id', sa.UUID(), nullable=False),
|
||||
sa.Column('track_sid', sa.String(length=64), nullable=False),
|
||||
sa.Column('egress_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('file_path', sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
'status',
|
||||
sa.Enum('recording', 'recorded', 'transcribed', 'failed', name='audio_track_status'),
|
||||
server_default='recording',
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('ended_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('segments', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
['participant_id'], ['conference_participants.id'], ondelete='CASCADE'
|
||||
),
|
||||
sa.ForeignKeyConstraint(['session_id'], ['conference_sessions.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('session_id', 'track_sid', name='uq_session_track'),
|
||||
)
|
||||
op.create_index(
|
||||
'ix_session_audio_tracks_session_id', 'session_audio_tracks', ['session_id']
|
||||
)
|
||||
op.create_index(
|
||||
'ix_session_audio_tracks_session_id_status',
|
||||
'session_audio_tracks',
|
||||
['session_id', 'status'],
|
||||
)
|
||||
|
||||
_clear_dev_phrases(bind)
|
||||
op.drop_constraint('phrases_user_id_fkey', 'phrases', type_='foreignkey')
|
||||
op.drop_column('phrases', 'user_id')
|
||||
op.add_column('phrases', sa.Column('participant_id', sa.UUID(), nullable=False))
|
||||
op.create_foreign_key(
|
||||
'phrases_participant_id_fkey',
|
||||
'phrases', 'conference_participants',
|
||||
['participant_id'], ['id'], ondelete='CASCADE',
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema.
|
||||
|
||||
Разрушительная миграция для `phrases` (см. докстринг ревизии выше) —
|
||||
downgrade восстанавливает структуру колонки, но не исходные данные.
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
|
||||
_clear_dev_phrases(bind)
|
||||
op.drop_constraint('phrases_participant_id_fkey', 'phrases', type_='foreignkey')
|
||||
op.drop_column('phrases', 'participant_id')
|
||||
op.add_column('phrases', sa.Column('user_id', sa.UUID(), nullable=False))
|
||||
op.create_foreign_key('phrases_user_id_fkey', 'phrases', 'users', ['user_id'], ['id'])
|
||||
|
||||
op.drop_index('ix_session_audio_tracks_session_id_status', table_name='session_audio_tracks')
|
||||
op.drop_index('ix_session_audio_tracks_session_id', table_name='session_audio_tracks')
|
||||
op.drop_table('session_audio_tracks')
|
||||
postgresql.ENUM(name='audio_track_status').drop(bind, checkfirst=True)
|
||||
49
backend/alembic/versions/9d37822e4513_teams.py
Normal file
49
backend/alembic/versions/9d37822e4513_teams.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""teams
|
||||
|
||||
Справочник команд и привязка пользователя к команде:
|
||||
- `teams` — id/name (уникально)/created_at;
|
||||
- `users.team_id` — необязательная ссылка на команду, `ON DELETE SET NULL`
|
||||
(удаление команды не удаляет пользователей, только снимает привязку).
|
||||
|
||||
Revision ID: 9d37822e4513
|
||||
Revises: 5970bf64fc43
|
||||
Create Date: 2026-07-18 03:00:27.636900
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9d37822e4513'
|
||||
down_revision: Union[str, Sequence[str], None] = '5970bf64fc43'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table(
|
||||
'teams',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column(
|
||||
'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name'),
|
||||
)
|
||||
op.add_column('users', sa.Column('team_id', sa.UUID(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
'fk_users_team_id_teams', 'users', 'teams', ['team_id'], ['id'], ondelete='SET NULL'
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_constraint('fk_users_team_id_teams', 'users', type_='foreignkey')
|
||||
op.drop_column('users', 'team_id')
|
||||
op.drop_table('teams')
|
||||
@@ -0,0 +1,76 @@
|
||||
"""conference invitees and avatars
|
||||
|
||||
Реализует раздел «Модель данных» ADR-003
|
||||
(`docs/architecture/adr/003-conference-invitees.md`):
|
||||
- новая таблица `conference_invitees` — приглашённые НА КОНФЕРЕНЦИЮ
|
||||
(зарегистрированный `user_id` ИЛИ внешний `email`, ровно одна identity);
|
||||
организатор в таблице не хранится (выводится из `conferences.owner_id`);
|
||||
- `users.avatar_path` — путь к загруженному аватару,
|
||||
`NULL` — заглушка с инициалами на фронте.
|
||||
|
||||
Revision ID: d87681e12784
|
||||
Revises: 504791847d4f
|
||||
Create Date: 2026-07-19 12:03:06.294730
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd87681e12784'
|
||||
down_revision: Union[str, Sequence[str], None] = '504791847d4f'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table(
|
||||
'conference_invitees',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('conference_id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=True),
|
||||
sa.Column('email', sa.String(length=255), nullable=True),
|
||||
sa.Column(
|
||||
'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
'(user_id IS NOT NULL)::int + (email IS NOT NULL)::int = 1',
|
||||
name='ck_conference_invitees_single_identity',
|
||||
),
|
||||
sa.ForeignKeyConstraint(['conference_id'], ['conferences.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
# Частичные уникальные индексы (ADR-003, п.1): дубль по зарегистрированному
|
||||
# пользователю или по email (регистронезависимо — `lower(email)`, email
|
||||
# приложение хранит уже в lower-case, индекс — доп. страховка).
|
||||
op.create_index(
|
||||
'uq_conference_invitees_user',
|
||||
'conference_invitees',
|
||||
['conference_id', 'user_id'],
|
||||
unique=True,
|
||||
postgresql_where=sa.text('user_id IS NOT NULL'),
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX uq_conference_invitees_email
|
||||
ON conference_invitees (conference_id, lower(email))
|
||||
WHERE email IS NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
op.add_column('users', sa.Column('avatar_path', sa.String(length=512), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_column('users', 'avatar_path')
|
||||
|
||||
op.execute('DROP INDEX IF EXISTS uq_conference_invitees_email')
|
||||
op.drop_index('uq_conference_invitees_user', table_name='conference_invitees')
|
||||
op.drop_table('conference_invitees')
|
||||
366
backend/alembic/versions/f418dd65e7b1_dynamic_conferences.py
Normal file
366
backend/alembic/versions/f418dd65e7b1_dynamic_conferences.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""dynamic conferences (ADR-001)
|
||||
|
||||
Реализует раздел «Модель данных» ADR-001 (`docs/architecture/adr/001-dynamic-conferences-pivot.md`):
|
||||
- переименование `conferences` (сеанс) в `conference_sessions`;
|
||||
- новая сущность `conferences` (конференция: номер, ссылка, владелец, статус,
|
||||
закрепление/закрытость, расписание, recurrence);
|
||||
- backfill новых `conferences` из существующих `rooms` (по одной на комнату,
|
||||
на которую ссылается хотя бы один сеанс) и перевязка `conference_sessions`;
|
||||
- переименование `conference_id` -> `session_id` в `phrases`/`chat_messages`/
|
||||
`conference_participants`;
|
||||
- `conference_participants`: `user_id` NULLABLE + `guest_id` + CHECK «ровно
|
||||
одно из двух заполнено»;
|
||||
- новая таблица `guest_access`;
|
||||
- удаление `booking_participants`, `room_bookings` (вместе с ней уходит
|
||||
EXCLUDE-constraint — см. ADR-001, п.5) и `rooms`.
|
||||
|
||||
Продакшен-данных нет (см. контекст ADR-001) — backfill рассчитан на
|
||||
непустую тестовую/дев БД, но не падает и на пустой (см. `_backfill_conferences_from_rooms`).
|
||||
|
||||
Revision ID: f418dd65e7b1
|
||||
Revises: 299053c6f7b8
|
||||
Create Date: 2026-07-16 12:00:00.000000
|
||||
|
||||
"""
|
||||
import secrets
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f418dd65e7b1'
|
||||
down_revision: Union[str, Sequence[str], None] = '299053c6f7b8'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _generate_conference_number(used: set[str]) -> str:
|
||||
"""Сгенерировать 9-значный номер конференции, уникальный в рамках backfill.
|
||||
|
||||
Логика идентична `services/conference_ids.py::generate_number` — миграции
|
||||
не импортируют прикладной код (он может измениться со временем и
|
||||
сломать применение старых ревизий), поэтому продублирована здесь.
|
||||
"""
|
||||
while True:
|
||||
first_digit = str(secrets.randbelow(9) + 1)
|
||||
rest_digits = "".join(str(secrets.randbelow(10)) for _ in range(8))
|
||||
number = first_digit + rest_digits
|
||||
if number not in used:
|
||||
used.add(number)
|
||||
return number
|
||||
|
||||
|
||||
def _backfill_conferences_from_rooms(connection: Connection) -> None:
|
||||
"""Создать по одной `conferences`-записи на каждую `room`, встречающуюся в сеансах.
|
||||
|
||||
`slug` = `permanent_link` комнаты (сохраняет действующие постоянные
|
||||
ссылки), `status='ended'` (это уже прожитая история, а не активная
|
||||
конференция), `owner_id` не определён (комнаты были общими). На пустой
|
||||
БД (нет строк `rooms`) цикл просто не выполняется.
|
||||
"""
|
||||
rooms = connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT DISTINCT r.id, r.name, r.permanent_link, r.is_pinned, r.created_at
|
||||
FROM rooms r
|
||||
WHERE EXISTS (SELECT 1 FROM conference_sessions cs WHERE cs.room_id = r.id)
|
||||
"""
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
used_numbers: set[str] = set()
|
||||
for room in rooms:
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO conferences
|
||||
(id, number, slug, title, status, is_pinned, created_at)
|
||||
VALUES
|
||||
(gen_random_uuid(), :number, :slug, :title, 'ended', :is_pinned, :created_at)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"number": _generate_conference_number(used_numbers),
|
||||
"slug": room["permanent_link"],
|
||||
"title": room["name"],
|
||||
"is_pinned": room["is_pinned"],
|
||||
"created_at": room["created_at"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
bind = op.get_bind()
|
||||
|
||||
# 1. conferences (сеанс) -> conference_sessions. FK-constraints и данные
|
||||
# сохраняются автоматически (Postgres переносит их вместе с таблицей,
|
||||
# constraint-имена, унаследованные от старого имени таблицы, не переименовываются —
|
||||
# это косметика, на работу не влияет). Индексы на удаляемой ниже колонке
|
||||
# `room_id` пересоздаём под новую модель.
|
||||
op.rename_table('conferences', 'conference_sessions')
|
||||
op.drop_index('ix_conferences_room_id_t_start', table_name='conference_sessions')
|
||||
op.drop_index('ix_conferences_pipeline_status', table_name='conference_sessions')
|
||||
op.create_index(
|
||||
'ix_conference_sessions_pipeline_status', 'conference_sessions', ['pipeline_status']
|
||||
)
|
||||
|
||||
# 2. Новая сущность Conference + её ENUM жизненного цикла (ADR-001, п.2).
|
||||
# Тип создаётся автоматически вместе с таблицей (create_type=True по
|
||||
# умолчанию) — отдельный `.create()` здесь не нужен и приводит к
|
||||
# DuplicateObjectError при повторном создании тем же вызовом create_table.
|
||||
conference_status = postgresql.ENUM('scheduled', 'active', 'ended', name='conference_status')
|
||||
op.create_table(
|
||||
'conferences',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('number', sa.String(length=9), nullable=False),
|
||||
sa.Column('slug', sa.String(length=22), nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=True),
|
||||
sa.Column('owner_id', sa.UUID(), nullable=True),
|
||||
sa.Column('status', conference_status, server_default='scheduled', nullable=False),
|
||||
sa.Column('is_pinned', sa.Boolean(), server_default='false', nullable=False),
|
||||
sa.Column('is_closed', sa.Boolean(), server_default='false', nullable=False),
|
||||
sa.Column('password_hash', sa.Text(), nullable=True),
|
||||
sa.Column('scheduled_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('duration_minutes', sa.Integer(), nullable=True),
|
||||
sa.Column('recurrence', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('ended_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
'is_closed = false OR password_hash IS NOT NULL',
|
||||
name='ck_conferences_closed_requires_password',
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
'recurrence IS NULL OR is_pinned = true',
|
||||
name='ck_conferences_recurrence_requires_pinned',
|
||||
),
|
||||
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('number'),
|
||||
sa.UniqueConstraint('slug'),
|
||||
)
|
||||
|
||||
# 3. Backfill: одна конференция на каждую room, на которую ссылались сеансы.
|
||||
_backfill_conferences_from_rooms(bind)
|
||||
|
||||
# 4. Перевязка: conference_sessions получает conference_id, найденный через
|
||||
# исходный room_id (join по slug == permanent_link, который backfill
|
||||
# сохранил равным исходной ссылке комнаты), после чего room_id/booking_id уходят.
|
||||
op.add_column('conference_sessions', sa.Column('conference_id', sa.UUID(), nullable=True))
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE conference_sessions cs
|
||||
SET conference_id = c.id
|
||||
FROM rooms r
|
||||
JOIN conferences c ON c.slug = r.permanent_link
|
||||
WHERE cs.room_id = r.id
|
||||
"""
|
||||
)
|
||||
op.alter_column('conference_sessions', 'conference_id', nullable=False)
|
||||
op.create_foreign_key(
|
||||
'conference_sessions_conference_id_fkey',
|
||||
'conference_sessions', 'conferences',
|
||||
['conference_id'], ['id'], ondelete='CASCADE',
|
||||
)
|
||||
op.create_index(
|
||||
'ix_conference_sessions_conference_id_t_start',
|
||||
'conference_sessions', ['conference_id', 't_start'],
|
||||
)
|
||||
op.drop_constraint('conferences_room_id_fkey', 'conference_sessions', type_='foreignkey')
|
||||
op.drop_constraint('conferences_booking_id_fkey', 'conference_sessions', type_='foreignkey')
|
||||
op.drop_column('conference_sessions', 'room_id')
|
||||
op.drop_column('conference_sessions', 'booking_id')
|
||||
|
||||
# 5. conference_id -> session_id в phrases/chat_messages/conference_participants.
|
||||
# FK на conference_sessions(id) сохраняется автоматически (см. п.1); индексы
|
||||
# переименовываются вслед за колонкой.
|
||||
op.alter_column('phrases', 'conference_id', new_column_name='session_id')
|
||||
op.execute('ALTER INDEX ix_phrases_conference_id_t_start RENAME TO ix_phrases_session_id_t_start')
|
||||
|
||||
op.alter_column('chat_messages', 'conference_id', new_column_name='session_id')
|
||||
op.execute(
|
||||
'ALTER INDEX ix_chat_messages_conference_id_created_at '
|
||||
'RENAME TO ix_chat_messages_session_id_created_at'
|
||||
)
|
||||
|
||||
op.alter_column('conference_participants', 'conference_id', new_column_name='session_id')
|
||||
op.execute(
|
||||
'ALTER INDEX ix_conference_participants_conference_id '
|
||||
'RENAME TO ix_conference_participants_session_id'
|
||||
)
|
||||
|
||||
# 6. guest_access — создаётся до правки conference_participants, т.к. её
|
||||
# новый guest_id ссылается на эту таблицу (порядок из-за FK-зависимости
|
||||
# отличается от порядка перечисления в ADR-001, итоговая схема та же).
|
||||
op.create_table(
|
||||
'guest_access',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('conference_id', sa.UUID(), nullable=False),
|
||||
sa.Column('display_name', sa.String(length=255), nullable=False),
|
||||
sa.Column('email', sa.String(length=320), nullable=True),
|
||||
sa.Column(
|
||||
'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(['conference_id'], ['conferences.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
# 7. conference_participants: user_id nullable, guest_id, CHECK «ровно одно из двух».
|
||||
op.alter_column('conference_participants', 'user_id', nullable=True)
|
||||
op.add_column('conference_participants', sa.Column('guest_id', sa.UUID(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
'conference_participants_guest_id_fkey',
|
||||
'conference_participants', 'guest_access',
|
||||
['guest_id'], ['id'],
|
||||
)
|
||||
op.create_check_constraint(
|
||||
'ck_conference_participants_exactly_one_identity',
|
||||
'conference_participants',
|
||||
'(user_id IS NOT NULL)::int + (guest_id IS NOT NULL)::int = 1',
|
||||
)
|
||||
|
||||
# 8. Комнаты и бронирование уходят вместе с EXCLUDE-constraint'ом
|
||||
# (ADR-001, п.5); список допущенных участников брони не переносится —
|
||||
# закрытая конференция теперь защищена только паролем.
|
||||
op.drop_table('booking_participants')
|
||||
op.drop_table('room_bookings')
|
||||
op.drop_table('rooms')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema.
|
||||
|
||||
Разрушительная миграция (ADR-001: «продакшен-данных нет — допустима
|
||||
структурная миграция»): downgrade восстанавливает схему, но не исходные
|
||||
данные комнат/броней/номеров — они не хранились обратимо после backfill.
|
||||
"""
|
||||
op.create_table(
|
||||
'rooms',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('is_pinned', sa.Boolean(), server_default='false', nullable=False),
|
||||
sa.Column('permanent_link', sa.String(length=64), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
|
||||
sa.Column(
|
||||
'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('permanent_link'),
|
||||
)
|
||||
op.create_table(
|
||||
'room_bookings',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('room_id', sa.UUID(), nullable=False),
|
||||
sa.Column('organizer_id', sa.UUID(), nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=True),
|
||||
sa.Column('period', postgresql.TSTZRANGE(), nullable=False),
|
||||
sa.Column('is_closed', sa.Boolean(), server_default='false', nullable=False),
|
||||
sa.Column('password_hash', sa.Text(), nullable=True),
|
||||
sa.Column('access_link', sa.String(length=43), nullable=False),
|
||||
sa.Column(
|
||||
'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'),
|
||||
nullable=False,
|
||||
),
|
||||
postgresql.ExcludeConstraint(
|
||||
(sa.column('room_id'), '='), (sa.column('period'), '&&'),
|
||||
using='gist', name='excl_room_bookings_overlap',
|
||||
),
|
||||
sa.CheckConstraint('NOT isempty(period)', name='ck_room_bookings_period_not_empty'),
|
||||
sa.ForeignKeyConstraint(['organizer_id'], ['users.id'], ondelete='RESTRICT'),
|
||||
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('access_link'),
|
||||
)
|
||||
op.create_table(
|
||||
'booking_participants',
|
||||
sa.Column('booking_id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['booking_id'], ['room_bookings.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('booking_id', 'user_id'),
|
||||
)
|
||||
|
||||
op.drop_constraint(
|
||||
'ck_conference_participants_exactly_one_identity', 'conference_participants',
|
||||
type_='check',
|
||||
)
|
||||
op.drop_constraint(
|
||||
'conference_participants_guest_id_fkey', 'conference_participants', type_='foreignkey'
|
||||
)
|
||||
op.drop_column('conference_participants', 'guest_id')
|
||||
op.alter_column('conference_participants', 'user_id', nullable=False)
|
||||
|
||||
op.drop_table('guest_access')
|
||||
|
||||
op.execute(
|
||||
'ALTER INDEX ix_conference_participants_session_id '
|
||||
'RENAME TO ix_conference_participants_conference_id'
|
||||
)
|
||||
op.alter_column('conference_participants', 'session_id', new_column_name='conference_id')
|
||||
|
||||
op.execute(
|
||||
'ALTER INDEX ix_chat_messages_session_id_created_at '
|
||||
'RENAME TO ix_chat_messages_conference_id_created_at'
|
||||
)
|
||||
op.alter_column('chat_messages', 'session_id', new_column_name='conference_id')
|
||||
|
||||
op.execute('ALTER INDEX ix_phrases_session_id_t_start RENAME TO ix_phrases_conference_id_t_start')
|
||||
op.alter_column('phrases', 'session_id', new_column_name='conference_id')
|
||||
|
||||
op.add_column('conference_sessions', sa.Column('room_id', sa.UUID(), nullable=True))
|
||||
op.add_column('conference_sessions', sa.Column('booking_id', sa.UUID(), nullable=True))
|
||||
|
||||
# Реконструируем rooms из conferences, на которые ссылаются сеансы —
|
||||
# операция, обратная backfill'у в upgrade(). `is_active` не хранилось
|
||||
# раздельно от новой модели — восстанавливаем как `true`.
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO rooms (id, name, permanent_link, is_pinned, is_active, created_at)
|
||||
SELECT gen_random_uuid(), COALESCE(c.title, c.slug), c.slug, c.is_pinned, true, c.created_at
|
||||
FROM conferences c
|
||||
WHERE EXISTS (SELECT 1 FROM conference_sessions cs WHERE cs.conference_id = c.id)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE conference_sessions cs
|
||||
SET room_id = r.id
|
||||
FROM conferences c
|
||||
JOIN rooms r ON r.permanent_link = c.slug
|
||||
WHERE cs.conference_id = c.id
|
||||
"""
|
||||
)
|
||||
op.create_foreign_key(
|
||||
'conferences_room_id_fkey', 'conference_sessions', 'rooms', ['room_id'], ['id'],
|
||||
ondelete='CASCADE',
|
||||
)
|
||||
op.create_foreign_key(
|
||||
'conferences_booking_id_fkey', 'conference_sessions', 'room_bookings', ['booking_id'],
|
||||
['id'], ondelete='SET NULL',
|
||||
)
|
||||
op.drop_index('ix_conference_sessions_conference_id_t_start', table_name='conference_sessions')
|
||||
op.drop_constraint(
|
||||
'conference_sessions_conference_id_fkey', 'conference_sessions', type_='foreignkey'
|
||||
)
|
||||
op.drop_column('conference_sessions', 'conference_id')
|
||||
op.alter_column('conference_sessions', 'room_id', nullable=False)
|
||||
|
||||
op.drop_table('conferences')
|
||||
postgresql.ENUM(name='conference_status').drop(op.get_bind(), checkfirst=True)
|
||||
|
||||
op.drop_index('ix_conference_sessions_pipeline_status', table_name='conference_sessions')
|
||||
op.create_index(
|
||||
'ix_conferences_room_id_t_start', 'conference_sessions', ['room_id', 't_start']
|
||||
)
|
||||
op.create_index(
|
||||
'ix_conferences_pipeline_status', 'conference_sessions', ['pipeline_status']
|
||||
)
|
||||
op.rename_table('conference_sessions', 'conferences')
|
||||
0
backend/api/__init__.py
Normal file
0
backend/api/__init__.py
Normal file
464
backend/api/admin.py
Normal file
464
backend/api/admin.py
Normal 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
188
backend/api/auth.py
Normal 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
152
backend/api/chat.py
Normal 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
255
backend/api/conferences.py
Normal 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
72
backend/api/deps.py
Normal 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
40
backend/api/health.py
Normal 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,
|
||||
}
|
||||
63
backend/api/livekit_webhook.py
Normal file
63
backend/api/livekit_webhook.py
Normal 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
125
backend/api/metrics.py
Normal 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 скрейпит
|
||||
редко (обычно раз в 15–30с), нагрузка пренебрежимо мала.
|
||||
"""
|
||||
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
31
backend/api/teams.py
Normal 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
145
backend/api/users.py
Normal 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
0
backend/core/__init__.py
Normal file
138
backend/core/config.py
Normal file
138
backend/core/config.py
Normal 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` по выбранному пресету (1–5), 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, пресеты 1–4;
|
||||
`.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
24
backend/core/db.py
Normal 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
|
||||
12
backend/core/plugins/__init__.py
Normal file
12
backend/core/plugins/__init__.py
Normal 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
|
||||
84
backend/core/plugins/config.py
Normal file
84
backend/core/plugins/config.py
Normal 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
|
||||
47
backend/core/plugins/factory.py
Normal file
47
backend/core/plugins/factory.py
Normal 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]
|
||||
141
backend/core/plugins/faster_whisper.py
Normal file
141
backend/core/plugins/faster_whisper.py
Normal 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
|
||||
36
backend/core/plugins/null.py
Normal file
36
backend/core/plugins/null.py
Normal 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 ""
|
||||
203
backend/core/plugins/qwen_local.py
Normal file
203
backend/core/plugins/qwen_local.py
Normal 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()
|
||||
15
backend/core/plugins/summarizer.py
Normal file
15
backend/core/plugins/summarizer.py
Normal 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:
|
||||
"""Создать резюме переданной трансцрибции."""
|
||||
...
|
||||
25
backend/core/plugins/transcriber.py
Normal file
25
backend/core/plugins/transcriber.py
Normal 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` в список сегментов."""
|
||||
...
|
||||
36
backend/core/rate_limit.py
Normal file
36
backend/core/rate_limit.py
Normal 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
9
backend/core/redis.py
Normal 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
71
backend/core/security.py
Normal 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])
|
||||
5
backend/core/summarization/__init__.py
Normal file
5
backend/core/summarization/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Пакет чистых функций суммаризации: чанкинг транскрипта и подсчёт токенов.
|
||||
|
||||
Плагин `QwenLocal` использует эти функции как строительные
|
||||
блоки map-reduce; сам пакет не знает про LLM и HTTP.
|
||||
"""
|
||||
156
backend/core/summarization/chunking.py
Normal file
156
backend/core/summarization/chunking.py
Normal 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: верхняя граница токенов на чанк (ТЗ: 3–8k).
|
||||
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
|
||||
155
backend/core/summarization/llm_client.py
Normal file
155
backend/core/summarization/llm_client.py
Normal 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()
|
||||
51
backend/core/summarization/tokens.py
Normal file
51
backend/core/summarization/tokens.py
Normal 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
97
backend/main.py
Normal 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()
|
||||
39
backend/models/__init__.py
Normal file
39
backend/models/__init__.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Пакет ORM-моделей.
|
||||
|
||||
Импортирует все модульные модели, чтобы `Base.metadata` был полностью
|
||||
заполнен для автогенерации Alembic.
|
||||
"""
|
||||
|
||||
from models.audio_track import SessionAudioTrack
|
||||
from models.base import Base
|
||||
from models.chat import ChatMessage
|
||||
from models.conference import Conference
|
||||
from models.email_delivery import EmailDelivery
|
||||
from models.email_verification import EmailVerificationToken
|
||||
from models.guest import GuestAccess
|
||||
from models.instance_setting import InstanceSetting
|
||||
from models.invitee import ConferenceInvitee
|
||||
from models.participant import ConferenceParticipant
|
||||
from models.phrase import Phrase
|
||||
from models.session import ConferenceSession
|
||||
from models.team import Team
|
||||
from models.user import User
|
||||
from models.webhook_event import LivekitWebhookEvent
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"ChatMessage",
|
||||
"Conference",
|
||||
"ConferenceInvitee",
|
||||
"ConferenceParticipant",
|
||||
"ConferenceSession",
|
||||
"EmailDelivery",
|
||||
"EmailVerificationToken",
|
||||
"GuestAccess",
|
||||
"InstanceSetting",
|
||||
"LivekitWebhookEvent",
|
||||
"Phrase",
|
||||
"SessionAudioTrack",
|
||||
"Team",
|
||||
"User",
|
||||
]
|
||||
65
backend/models/audio_track.py
Normal file
65
backend/models/audio_track.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Модель SessionAudioTrack — записанный per-track аудиотрек участника сеанса.
|
||||
|
||||
Каждая строка соответствует одной аудио-дорожке (микрофону) одного участника
|
||||
одного сеанса, записываемой отдельным LiveKit Track Egress. Диаризация не
|
||||
нужна: трек = спикер, атрибуция — к `conference_participants` (ADR-002).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, Index, String, Text, UniqueConstraint, text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from models.base import Base
|
||||
|
||||
AudioTrackStatus = Enum(
|
||||
"recording",
|
||||
"recorded",
|
||||
"transcribed",
|
||||
"failed",
|
||||
name="audio_track_status",
|
||||
)
|
||||
|
||||
|
||||
class SessionAudioTrack(Base):
|
||||
"""Одна аудиодорожка сеанса, записанная LiveKit Track Egress в отдельный файл.
|
||||
|
||||
`status` — состояние per-track шага пайплайна (независимо от
|
||||
`conference_sessions.pipeline_status`):
|
||||
`recording` (egress запущен) -> `recorded` (файл финализирован по
|
||||
`egress_ended`) -> `transcribed` (сегменты Whisper сохранены в `segments`)
|
||||
либо `failed` на любом шаге.
|
||||
"""
|
||||
|
||||
__tablename__ = "session_audio_tracks"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("session_id", "track_sid", name="uq_session_track"),
|
||||
Index("ix_session_audio_tracks_session_id", "session_id"),
|
||||
Index("ix_session_audio_tracks_session_id_status", "session_id", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
|
||||
)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("conference_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
participant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("conference_participants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
track_sid: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
egress_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
file_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
AudioTrackStatus, nullable=False, server_default="recording"
|
||||
)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
segments: Mapped[list[dict[str, Any]] | None] = mapped_column(JSONB, nullable=True)
|
||||
7
backend/models/base.py
Normal file
7
backend/models/base.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Декларативная база для всех ORM моделей."""
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Общая декларативная база; все модели наследуют от этого."""
|
||||
60
backend/models/chat.py
Normal file
60
backend/models/chat.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Модель сообщения чата — текстовый чат в конференции (WS).
|
||||
|
||||
Автор — зарегистрированный пользователь ИЛИ гость (ровно один из `user_id`/
|
||||
`guest_access_id` заполнен, аналогично `ConferenceParticipant` из ADR-001,
|
||||
п.6). `author_name` — снапшот отображаемого имени из LiveKit access-токена
|
||||
на момент отправки сообщения: не связан жёстко с текущим именем
|
||||
пользователя/гостя, переживает их последующее переименование или удаление
|
||||
гостевой записи (`ON DELETE CASCADE` у `guest_access_id` относится к самому
|
||||
сообщению, а не к отображаемому имени в истории чата).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Identity,
|
||||
Index,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from models.base import Base
|
||||
|
||||
|
||||
class ChatMessage(Base):
|
||||
"""Одно сообщение чата, опубликованное в конференции."""
|
||||
|
||||
__tablename__ = "chat_messages"
|
||||
__table_args__ = (
|
||||
Index("ix_chat_messages_session_id_created_at", "session_id", "created_at"),
|
||||
CheckConstraint(
|
||||
"user_id IS NOT NULL OR guest_access_id IS NOT NULL",
|
||||
name="ck_chat_messages_author",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("conference_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
guest_access_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("guest_access.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
author_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
89
backend/models/conference.py
Normal file
89
backend/models/conference.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Модель Conference — конференция как пользовательская сущность (ADR-001).
|
||||
|
||||
Конференция — постоянная сущность с номером и ссылкой; каждый её запуск
|
||||
порождает отдельную запись `ConferenceSession` (см. `models/session.py`),
|
||||
которая и является единицей AI-пайплайна пост-обработки.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from models.base import Base
|
||||
|
||||
# Жизненный цикл конференции (ADR-001, п.2): `draft` не нужен — создание
|
||||
# атомарно из формы; `is_pinned` — ортогональный флаг, а не статус.
|
||||
ConferenceStatus = Enum(
|
||||
"scheduled",
|
||||
"active",
|
||||
"ended",
|
||||
name="conference_status",
|
||||
)
|
||||
|
||||
|
||||
class Conference(Base):
|
||||
"""Конференция — номер, постоянная ссылка, владелец, признаки закрепления/закрытости."""
|
||||
|
||||
__tablename__ = "conferences"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"is_closed = false OR password_hash IS NOT NULL",
|
||||
name="ck_conferences_closed_requires_password",
|
||||
),
|
||||
CheckConstraint(
|
||||
"recurrence IS NULL OR is_pinned = true",
|
||||
name="ck_conferences_recurrence_requires_pinned",
|
||||
),
|
||||
CheckConstraint(
|
||||
"summary_recipients IN ('all', 'owner')",
|
||||
name="ck_conferences_summary_recipients",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
|
||||
)
|
||||
# 9 десятичных цифр, первая 1..9 (services/conference_ids.py::generate_number).
|
||||
number: Mapped[str] = mapped_column(String(9), unique=True, nullable=False)
|
||||
# secrets.token_urlsafe(8) — 11 base64url-символов (services/conference_ids.py::generate_slug).
|
||||
slug: Mapped[str] = mapped_column(String(22), unique=True, nullable=False)
|
||||
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
owner_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
ConferenceStatus, nullable=False, server_default="scheduled"
|
||||
)
|
||||
is_pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
is_closed: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
scheduled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Pydantic-модель RecurrenceRule (services/recurrence.py), сериализованная в JSONB.
|
||||
recurrence: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Переопределение рассылки саммари конкретной конференции;
|
||||
# NULL = использовать дефолт инстанса (`instance_settings['summary_recipients']`).
|
||||
summary_recipients: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
# Счётчик изменений расписания для VEVENT `SEQUENCE` (.ics):
|
||||
# растёт при правке `scheduled_at`/`duration_minutes`/`recurrence`/`title`,
|
||||
# чтобы календарные клиенты обновляли уже принятое приглашение по UID.
|
||||
ics_sequence: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
59
backend/models/email_delivery.py
Normal file
59
backend/models/email_delivery.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Модель EmailDelivery — факт отправки письма.
|
||||
|
||||
Идемпотентность рассылки саммари (`kind='summary'`): уникальный частичный
|
||||
индекс `(session_id, recipient_email)` не даёт повторной отправке того же
|
||||
письма тому же адресату. Приглашения (`kind='invitation'`) — только журнал,
|
||||
без unique (изменение расписания обязано переслать обновление).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, func, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from models.base import Base
|
||||
|
||||
|
||||
class EmailDelivery(Base):
|
||||
"""Запись об отправленном письме: саммари (`session_id`) либо приглашение (`conference_id`)."""
|
||||
|
||||
__tablename__ = "email_deliveries"
|
||||
__table_args__ = (
|
||||
CheckConstraint("kind IN ('summary', 'invitation')", name="ck_email_deliveries_kind"),
|
||||
CheckConstraint(
|
||||
"(kind = 'summary') = (session_id IS NOT NULL)",
|
||||
name="ck_email_deliveries_summary_has_session",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(kind = 'invitation') = (conference_id IS NOT NULL)",
|
||||
name="ck_email_deliveries_invitation_has_conference",
|
||||
),
|
||||
Index(
|
||||
"uq_email_deliveries_summary",
|
||||
"session_id",
|
||||
"recipient_email",
|
||||
unique=True,
|
||||
postgresql_where=text("kind = 'summary'"),
|
||||
),
|
||||
Index("ix_email_deliveries_conference", "conference_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
|
||||
)
|
||||
session_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("conference_sessions.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
conference_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("conferences.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
# Хранится в lower() (нормализация — на уровне сервиса-отправителя).
|
||||
recipient_email: Mapped[str] = mapped_column(String(320), nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
sent_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
30
backend/models/email_verification.py
Normal file
30
backend/models/email_verification.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Модель токена подтверждения email пользователя."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Text, func, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from models.base import Base
|
||||
|
||||
|
||||
class EmailVerificationToken(Base):
|
||||
"""Одноразовый токен подтверждения email; хранится только sha256-хэш токена."""
|
||||
|
||||
__tablename__ = "email_verification_tokens"
|
||||
__table_args__ = (Index("ix_email_verification_tokens_user_id", "user_id"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
28
backend/models/guest.py
Normal file
28
backend/models/guest.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Модель GuestAccess — гость, представившийся при входе в конференцию (ADR-001, п.6)."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from models.base import Base
|
||||
|
||||
|
||||
class GuestAccess(Base):
|
||||
"""Запись о госте: имя обязательно, email — факультативен (для рассылки саммари)."""
|
||||
|
||||
__tablename__ = "guest_access"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
|
||||
)
|
||||
conference_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("conferences.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
27
backend/models/instance_setting.py
Normal file
27
backend/models/instance_setting.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Модель InstanceSetting — key-value настройки инстанса (JSONB).
|
||||
|
||||
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
|
||||
`ai_level`, `summary_recipients`, `display_timezone`) — новая настройка не
|
||||
требует миграции, только запись новой строки (см. `services/instance_settings.py`).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from models.base import Base
|
||||
|
||||
|
||||
class InstanceSetting(Base):
|
||||
"""Одна настройка инстанса: `key` — имя секции, `value` — JSONB-значение."""
|
||||
|
||||
__tablename__ = "instance_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
value: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
61
backend/models/invitee.py
Normal file
61
backend/models/invitee.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Модель ConferenceInvitee — приглашённый на конференцию (ADR-003).
|
||||
|
||||
Не путать с `ConferenceParticipant` (ADR-002) — та таблица фиксирует
|
||||
ФАКТИЧЕСКОЕ присутствие в сеансе, эта — состав, заданный организатором ДО
|
||||
конференции. Организатор в этой таблице не хранится: он всегда выводится из
|
||||
`conferences.owner_id` (см. ADR-003, п.2) — инвариант «организатор всегда в
|
||||
составе и неудаляем» обеспечен конструктивно, без триггеров.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, func, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from models.base import Base
|
||||
|
||||
|
||||
class ConferenceInvitee(Base):
|
||||
"""Приглашённый: ровно одна identity — зарегистрированный `user_id` ИЛИ внешний `email`."""
|
||||
|
||||
__tablename__ = "conference_invitees"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"(user_id IS NOT NULL)::int + (email IS NOT NULL)::int = 1",
|
||||
name="ck_conference_invitees_single_identity",
|
||||
),
|
||||
# Частичные уникальные индексы (ADR-003, п.1): без дублей приглашения
|
||||
# одного и того же пользователя/email на одну конференцию.
|
||||
# `lower(email)` — регистронезависимо (email нормализуется в lower-case
|
||||
# уже на уровне схемы `InviteeIn`, индекс — доп. страховка).
|
||||
Index(
|
||||
"uq_conference_invitees_user",
|
||||
"conference_id",
|
||||
"user_id",
|
||||
unique=True,
|
||||
postgresql_where=text("user_id IS NOT NULL"),
|
||||
),
|
||||
Index(
|
||||
"uq_conference_invitees_email",
|
||||
"conference_id",
|
||||
text("lower(email)"),
|
||||
unique=True,
|
||||
postgresql_where=text("email IS NOT NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
|
||||
)
|
||||
conference_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("conferences.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
44
backend/models/participant.py
Normal file
44
backend/models/participant.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Модель участника сеанса конференции — метки времени присутствия и identity (ADR-001, п.6)."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from models.base import Base
|
||||
|
||||
|
||||
class ConferenceParticipant(Base):
|
||||
"""Временное окно присутствия участника (пользователя ИЛИ гостя) в сеансе.
|
||||
|
||||
Ровно одно из `user_id`/`guest_id` заполнено (CHECK) — зарегистрированный
|
||||
участник и гость взаимоисключающие identity одной строки.
|
||||
"""
|
||||
|
||||
__tablename__ = "conference_participants"
|
||||
__table_args__ = (
|
||||
Index("ix_conference_participants_session_id", "session_id"),
|
||||
CheckConstraint(
|
||||
"(user_id IS NOT NULL)::int + (guest_id IS NOT NULL)::int = 1",
|
||||
name="ck_conference_participants_exactly_one_identity",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
|
||||
)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("conference_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
guest_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("guest_access.id"), nullable=True
|
||||
)
|
||||
joined_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
left_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
36
backend/models/phrase.py
Normal file
36
backend/models/phrase.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Модель Phrase — фраза транскрибации, атрибутированная к участнику сеанса (ADR-002)."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Identity, Index, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from models.base import Base
|
||||
|
||||
|
||||
class Phrase(Base):
|
||||
"""Одна восстановленная фраза, относящаяся к транскрибации конференции.
|
||||
|
||||
Атрибуция — к `conference_participants` (ADR-002), а не к `users`: гость
|
||||
без `user_id` проходит пайплайн наравне с зарегистрированным пользователем.
|
||||
"""
|
||||
|
||||
__tablename__ = "phrases"
|
||||
__table_args__ = (Index("ix_phrases_session_id_t_start", "session_id", "t_start"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True)
|
||||
participant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("conference_participants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("conference_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
data: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
t_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
t_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
55
backend/models/session.py
Normal file
55
backend/models/session.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Модель ConferenceSession — один запуск конференции, единица AI-пайплайна (ADR-001).
|
||||
|
||||
Таблица `conference_sessions` исторически получена переименованием старой
|
||||
`conferences` — семантика `pipeline_status` (идемпотентная статус-машина
|
||||
пост-обработки) при этом не менялась.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, Index, String, Text, func, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from models.base import Base
|
||||
|
||||
PIPELINE_STATUSES: tuple[str, ...] = (
|
||||
"recording",
|
||||
"transcribing",
|
||||
"summarizing",
|
||||
"notified",
|
||||
"failed",
|
||||
)
|
||||
"""Полный перечень статусов пайплайна — переиспользуется метриками Prometheus
|
||||
(`backend/api/metrics.py`), чтобы gauge по статусам не
|
||||
"пропадал" из `/metrics` для статусов без единого сеанса на момент scrape."""
|
||||
|
||||
PipelineStatus = Enum(*PIPELINE_STATUSES, name="pipeline_status")
|
||||
|
||||
|
||||
class ConferenceSession(Base):
|
||||
"""Один сеанс конференции и состояние его pipeline пост-обработки."""
|
||||
|
||||
__tablename__ = "conference_sessions"
|
||||
__table_args__ = (
|
||||
Index("ix_conference_sessions_conference_id_t_start", "conference_id", "t_start"),
|
||||
Index("ix_conference_sessions_pipeline_status", "pipeline_status"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
|
||||
)
|
||||
conference_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("conferences.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
t_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
t_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
summary_data: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
pipeline_status: Mapped[str] = mapped_column(
|
||||
PipelineStatus, nullable=False, server_default="recording"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
24
backend/models/team.py
Normal file
24
backend/models/team.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Модель Team — справочник команд для группировки пользователей."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, func, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from models.base import Base
|
||||
|
||||
|
||||
class Team(Base):
|
||||
"""Команда (справочник); привязка пользователя — `users.team_id` (SET NULL при удалении)."""
|
||||
|
||||
__tablename__ = "teams"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
40
backend/models/user.py
Normal file
40
backend/models/user.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Модель User."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, String, Text, func, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from models.base import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Зарегистрированный пользователь приложения."""
|
||||
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (CheckConstraint("role IN ('admin', 'user')", name="ck_users_role"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
|
||||
)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
name_user: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(16), nullable=False, server_default="user")
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
# Блокировка администратором: проверяется в `api/deps.py::_user_from_token`,
|
||||
# действует немедленно — до истечения уже выданного access-токена не ждём.
|
||||
is_blocked: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
# Привязка к команде (справочник `teams`):
|
||||
# необязательная, при удалении команды обнуляется (ON DELETE SET NULL).
|
||||
team_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("teams.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
# Путь к загруженному аватару (относительно `MEDIA_ROOT`):
|
||||
# `avatars/{user_id}.{ext}`; `NULL` — заглушка с инициалами на фронте.
|
||||
avatar_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
25
backend/models/webhook_event.py
Normal file
25
backend/models/webhook_event.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Модель для идемпотентной обработки webhook-событий LiveKit."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from models.base import Base
|
||||
|
||||
|
||||
class LivekitWebhookEvent(Base):
|
||||
"""Журнал полученных webhook-событий LiveKit.
|
||||
|
||||
`event_id` — первичный ключ; повторная доставка того же события
|
||||
(`INSERT ... ON CONFLICT DO NOTHING`) отклоняется на уровне БД в одной
|
||||
транзакции с эффектами обработчика (см. `api/livekit_webhook.py`).
|
||||
"""
|
||||
|
||||
__tablename__ = "livekit_webhook_events"
|
||||
|
||||
event_id: Mapped[str] = mapped_column(String(255), primary_key=True)
|
||||
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
received_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
115
backend/pyproject.toml
Normal file
115
backend/pyproject.toml
Normal 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 = [".."]
|
||||
0
backend/repositories/__init__.py
Normal file
0
backend/repositories/__init__.py
Normal file
133
backend/repositories/admin.py
Normal file
133
backend/repositories/admin.py
Normal 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)
|
||||
48
backend/repositories/chat.py
Normal file
48
backend/repositories/chat.py
Normal 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
|
||||
543
backend/repositories/conferences.py
Normal file
543
backend/repositories/conferences.py
Normal 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())
|
||||
59
backend/repositories/users.py
Normal file
59
backend/repositories/users.py
Normal 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())
|
||||
1
backend/schemas/__init__.py
Normal file
1
backend/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Pydantic-схемы (DTO) для входных/выходных данных API."""
|
||||
158
backend/schemas/admin.py
Normal file
158
backend/schemas/admin.py
Normal 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
113
backend/schemas/auth.py
Normal 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
83
backend/schemas/chat.py
Normal 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
|
||||
212
backend/schemas/conferences.py
Normal file
212
backend/schemas/conferences.py
Normal 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
|
||||
0
backend/scripts/__init__.py
Normal file
0
backend/scripts/__init__.py
Normal file
120
backend/scripts/apply_preset_settings.py
Normal file
120
backend/scripts/apply_preset_settings.py
Normal 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
51
backend/scripts/seed.py
Normal 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())
|
||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
98
backend/services/ai_levels.py
Normal file
98
backend/services/ai_levels.py
Normal 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}"
|
||||
137
backend/services/ai_tiers.py
Normal file
137
backend/services/ai_tiers.py
Normal 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
239
backend/services/auth.py
Normal 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
123
backend/services/avatars.py
Normal 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
218
backend/services/chat.py
Normal 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,
|
||||
)
|
||||
78
backend/services/conference_access.py
Normal file
78
backend/services/conference_access.py
Normal 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,
|
||||
)
|
||||
37
backend/services/conference_ids.py
Normal file
37
backend/services/conference_ids.py
Normal 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)
|
||||
670
backend/services/conferences.py
Normal file
670
backend/services/conferences.py
Normal 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
|
||||
58
backend/services/egress.py
Normal file
58
backend/services/egress.py
Normal 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
202
backend/services/email.py
Normal 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()
|
||||
161
backend/services/email_templates.py
Normal file
161
backend/services/email_templates.py
Normal 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} · {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
148
backend/services/ics.py
Normal 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
|
||||
390
backend/services/instance_settings.py
Normal file
390
backend/services/instance_settings.py
Normal 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"),
|
||||
)
|
||||
40
backend/services/invitations_producer.py
Normal file
40
backend/services/invitations_producer.py
Normal 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
|
||||
)
|
||||
43
backend/services/livekit_tokens.py
Normal file
43
backend/services/livekit_tokens.py
Normal 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()
|
||||
70
backend/services/pipeline_producer.py
Normal file
70
backend/services/pipeline_producer.py
Normal 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
|
||||
)
|
||||
70
backend/services/profile.py
Normal file
70
backend/services/profile.py
Normal 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
|
||||
165
backend/services/recurrence.py
Normal file
165
backend/services/recurrence.py
Normal 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
|
||||
357
backend/services/webhook_handlers.py
Normal file
357
backend/services/webhook_handlers.py
Normal 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
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
233
backend/tests/conftest.py
Normal file
233
backend/tests/conftest.py
Normal 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()
|
||||
16
backend/tests/fixtures/livekit/egress_ended.json
vendored
Normal file
16
backend/tests/fixtures/livekit/egress_ended.json
vendored
Normal 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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
13
backend/tests/fixtures/livekit/egress_ended_failed.json
vendored
Normal file
13
backend/tests/fixtures/livekit/egress_ended_failed.json
vendored
Normal 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
Reference in New Issue
Block a user