Первоначальная версия VidConf
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user