Compare commits

...

7 Commits

Author SHA1 Message Date
41aca319fc release: версия 0.0.37
Some checks are pending
CI / backend (push) Waiting to run
CI / frontend (push) Waiting to run
2026-08-10 16:10:01 +03:00
1ab146e4b0 fix(room): нормализовать сентинел "default" выбора устройства
usePersistentUserChoices хранит «устройство не выбрано» как литерал
"default", а не пустую строку — deviceId: userChoices.audioDeviceId ||
undefined на него не срабатывал, и в deviceId уходил сентинел. В Chrome
это давало OverconstrainedError на первом визите с лишним неудачным
проходом до фолбэка. Та же нормализация уже была сделана в 0.0.33 для
useDeviceCheckAccess.ts — здесь то же самое для комнаты.
2026-08-10 16:09:44 +03:00
9abe5b5102 fix(deploy): починить DATABASE_URL для локального контейнерного стенда
Корневой .env указывает DATABASE_URL на localhost (нужен только для
запуска backend на хосте вне контейнера), а compose подставлял эту
строку внутрь контейнеров backend/worker/worker-transcriber как есть —
localhost внутри контейнера это он сам, ConnectionRefused при любом
docker compose up без обходной переменной окружения (грабля с 04.08).

DATABASE_URL для контейнеров теперь всегда собирается в compose из
POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB с хостом postgres — так же,
как уже сделано для REDIS_URL. Прода не касается: install.sh (строка 380)
и так пересобирает DATABASE_URL при установке с хостом postgres.

Проверено на локальном стенде: docker compose --profile media up -d без
обходной переменной, backend/worker подключились к БД без ConnectionRefused.
2026-08-10 16:09:38 +03:00
6db142d45a docs(env): описать живые ключи .env.example, убрать мёртвые
MEDIA_URL и VITE_API_URL нигде не читаются (grep по backend/, frontend/src/,
deploy/ не даёт совпадений) — удалены. MEDIA_ROOT и EGRESS_START_TIMEOUT_S
живут в backend/core/config.py, но отсутствовали в .env.example — добавлены
с описанием. LIVEKIT_WS_URL читается в deploy/docker-compose.yml (egress) и
deploy/egress/egress.yaml, в Python-конфиге его нет — тоже описан.
2026-08-10 16:09:27 +03:00
1c2a9cd24a style(backend): разобрать 13 предупреждений ruff (E501)
Реальный код переформатирован (models/user.py, services/instance_settings.py,
tests/test_auth.py). Текст регламента обработки персональных данных вынесен
в services/consent_policy_text.py с точечным per-file-ignore E501 (как и
email_templates.py) — это прозаический шаблон документа, а не код, оборачивать
его строки ради лимита длины бессмысленно.
2026-08-10 16:09:19 +03:00
c4485d43b1 fix(metrics): развязать vidconf_pipeline_sessions с основным пулом БД
_refresh_pipeline_sessions_gauge и metrics_endpoint ходили через
Depends(get_session) — основной пул, разделяемый с API-запросами. В
инциденте 07.08 это дало 16 падений в api/metrics.py ровно тогда, когда
метрики были нужнее всего (пул исчерпан). db_up/db_pool_* уже были
развязаны в 0.0.32, эта метрика — нет (мешал тестовый харнесс).

Добавлен get_metrics_session (core/db.py) — отдельный движок с NullPool,
как у check_db_up, но с полноценной ORM-сессией для репозитория. Тестовый
харнесс (app-фикстура) подменяет её на ту же savepoint-сессию, что и
get_session, — иначе /metrics не видел бы данные теста.
2026-08-10 16:09:09 +03:00
0a13589612 fix(tests): изолировать тесты от instance_settings в общей dev-БД
Тесты читали ту же instance_settings, что и dev-стенд: выключение
chat/hand_queue в админке роняло пачку тестов, не связанных с самим
переключением (наступила сессия 0.0.28). Фикстура clean_instance_settings
(явная, не autouse — DELETE в savepoint держит блокировку строки до
конца внешней транзакции теста, автовключение на тестах с отдельными
подключениями к БД дало саморазблокировку) удаляет управляемые ключи
перед тестом, чтение конфигурации падает на дефолты pydantic-моделей.
Подключена в test_chat_ws.py и test_hand_queue_ws.py.

MANAGED_KEYS в services/instance_settings.py — единый список управляемых
ключей вместо локальной копии в тестовом файле.
2026-08-10 16:08:56 +03:00
16 changed files with 287 additions and 123 deletions

View File

@@ -2,6 +2,11 @@
POSTGRES_USER=vidconf POSTGRES_USER=vidconf
POSTGRES_PASSWORD=vidconf POSTGRES_PASSWORD=vidconf
POSTGRES_DB=vidconf POSTGRES_DB=vidconf
# DATABASE_URL нужен ТОЛЬКО для запуска backend/worker на хосте вне docker-сети
# (см. docs/deploy/dev-setup.md) — внутри контейнеров docker-compose.yml
# всегда собирает postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
# сам (сессия 37), это значение игнорируя. Держите пароль в этой строке
# синхронным с POSTGRES_PASSWORD.
DATABASE_URL=postgresql+asyncpg://vidconf:vidconf@localhost:5432/vidconf DATABASE_URL=postgresql+asyncpg://vidconf:vidconf@localhost:5432/vidconf
# --- Redis --- # --- Redis ---
@@ -128,7 +133,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) --- # --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой # install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно. # установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.36 VIDCONF_VERSION=0.0.37
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного # --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг, # `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
@@ -190,7 +195,22 @@ GRAFANA_ADMIN_PASSWORD=change-me-grafana
# ссылаются на них (проверено grep'ом при харденинге репозитория). Оставлены # ссылаются на них (проверено grep'ом при харденинге репозитория). Оставлены
# здесь только для полноты покрытия реальных ключей сервера; вычистить или # здесь только для полноты покрытия реальных ключей сервера; вычистить или
# начать использовать — по итогам Сессии 2 (bug hunt). --- # начать использовать — по итогам Сессии 2 (bug hunt). ---
MEDIA_URL=http://localhost/media/
VITE_API_URL=http://localhost
VITE_LIVEKIT_URL=ws://localhost:7880 VITE_LIVEKIT_URL=ws://localhost:7880
NEXT_PUBLIC_LIVEKIT_URL=ws://localhost:7880 NEXT_PUBLIC_LIVEKIT_URL=ws://localhost:7880
# --- Медиа (аватары пользователей, backend/core/config.py: media_root) ---
# Каталог, куда сохраняются загруженные файлы (аватары); раздаётся статикой
# по /media (dev) либо через nginx location /media/ в проде.
MEDIA_ROOT=media
# --- Egress (backend/core/config.py: egress_start_timeout_s) ---
# Таймаут ожидания ответа Track Egress (секунды). Когда egress-сервис в
# деплое не поднят (профиль transcribe отсутствует), не ждать его штатный
# (гораздо более долгий) таймаут на каждый вызов.
EGRESS_START_TIMEOUT_S=3.0
# --- LiveKit Egress (deploy/docker-compose.yml, сервис egress; см. также
# deploy/egress/egress.yaml) — WS-адрес LiveKit ИЗНУТРИ docker-сети, отдельно
# от LIVEKIT_URL/LIVEKIT_PUBLIC_URL выше (те — для backend/браузера). В
# backend/core/config.py не читается — потребитель только egress. ---
LIVEKIT_WS_URL=ws://livekit:7880

View File

@@ -3,6 +3,41 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/). проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [0.0.37] — 2026-08-10
Закрытие технического долга. Без изменений в продуктовой логике, кроме
исправления лишнего отказа при первом входе в комнату (см. «Исправлено»).
### Исправлено
- Тесты бэкенда были не изолированы от состояния `instance_settings`
(`chat`, `hand_queue` и др.) в общей dev-БД: выключение любого модуля в
админке локального стенда роняло не связанные с ним тесты. Изоляция —
через фикстуру `clean_instance_settings`, явно подключаемую в тестах,
которым нужен «чистый стол» по умолчанным значениям.
- `RoomPage.tsx`: сохранённый выбор устройства «по умолчанию»
(`usePersistentUserChoices`, литерал `"default"`) не отсекался
`|| undefined` — в Chrome это давало лишний `OverconstrainedError` на
первом визите (то же самое уже было исправлено для страницы проверки
устройств в 0.0.33).
- Локальный контейнерный стенд (`docker compose --profile media up`) не
поднимался «как есть» — `DATABASE_URL` из корневого `.env` (указывает на
`localhost`, нужен только для запуска backend на хосте) утекал внутрь
контейнеров и ronял подключение к БД. Теперь compose всегда собирает
строку подключения сам из `POSTGRES_USER`/`PASSWORD`/`DB` с хостом
`postgres`, как уже было сделано для `REDIS_URL`.
- `vidconf_pipeline_sessions` в `/metrics` читалась через основной пул БД —
на нагрузке могла отвалиться вместе с остальным API ровно тогда, когда
метрика нужнее всего (см. инцидент 07.08). Теперь читается через отдельный
движок, как `vidconf_db_up`/`vidconf_db_pool_*`.
### Изменено
- `.env.example`: убраны неиспользуемые `MEDIA_URL`/`VITE_API_URL`,
добавлены живые `MEDIA_ROOT`/`EGRESS_START_TIMEOUT_S`/`LIVEKIT_WS_URL`.
- 13 предупреждений `ruff` (длина строки): реальный код переформатирован,
текст регламента персональных данных вынесен в отдельный модуль
(`services/consent_policy_text.py`) — как и вёрстка email-писем, это
прозаический текст, а не код.
## [0.0.36] — 2026-08-10 ## [0.0.36] — 2026-08-10
Исправление к 0.0.35: замена фона не появлялась в Safari и Firefox. Исправление к 0.0.35: замена фона не появлялась в Safari и Firefox.

View File

@@ -1 +1 @@
0.0.36 0.0.37

View File

@@ -14,17 +14,19 @@ Redis) можно опросить обычным `await` вместо реал
и вовсе синхронный — настройки уже в памяти процесса). и вовсе синхронный — настройки уже в памяти процесса).
🔴 Метрики о состоянии основного пула БД (`vidconf_db_up`, 🔴 Метрики о состоянии основного пула БД (`vidconf_db_up`,
`vidconf_db_pool_*`) обязаны читаться БЕЗ обращения к самому пулу — иначе `vidconf_db_pool_*`, `vidconf_pipeline_sessions`) обязаны читаться БЕЗ
в момент его исчерпания (см. `.forcc/session-results/32-loadtest-07-08-debug.md`) обращения к самому пулу — иначе в момент его исчерпания (см.
эндпоинт метрик падал бы вместе со всем остальным ровно тогда, когда нужнее `.forcc/session-results/32-loadtest-07-08-debug.md`) эндпоинт метрик падал бы
всего. `vidconf_db_pool_*` — синхронный снимок `engine.pool` (см. вместе со всем остальным ровно тогда, когда нужнее всего. `vidconf_db_pool_*`
`core/db.py::db_pool_stats`), `vidconf_db_up` — отдельное соединение вне — синхронный снимок `engine.pool` (см. `core/db.py::db_pool_stats`),
основного пула (`core/db.py::check_db_up`). `_refresh_pipeline_sessions_gauge` `vidconf_db_up` — отдельное соединение вне основного пула
по-прежнему ходит через основной пул (`Depends(get_session)`, тестовый (`core/db.py::check_db_up`). `_refresh_pipeline_sessions_gauge` с сессии 37
харнесс подменяет её на savepoint-сессию — см. `tests/conftest.py`; развести тоже читает через отдельный движок (`Depends(get_metrics_session)`,
полностью, как `vidconf_db_up`, значило бы переделывать харнесс ради того же `core/db.py`) — тестовый харнесс подменяет её на savepoint-сессию теста так
эффекта — цена не оправдана, см. прецедент `f7c4fb4`/session 32), но обёрнута же, как `get_session` (см. `tests/conftest.py`). До сессии 37 она ходила
таймаутом и try/except, чтобы её недоступность не роняла остальные метрики. через основной пул (`Depends(get_session)`) — прецедент `f7c4fb4`/session 32;
дополнительная обёртка таймаутом и try/except ниже осталась как вторая
линия обороны на случай, если сама БД (а не пул) не отвечает.
""" """
import asyncio import asyncio
@@ -37,7 +39,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from starlette.routing import Match from starlette.routing import Match
from core.config import get_settings from core.config import get_settings
from core.db import check_db_up, db_pool_checked_out, get_session from core.db import check_db_up, db_pool_checked_out, get_metrics_session
from core.redis import redis_client from core.redis import redis_client
from models.session import PIPELINE_STATUSES from models.session import PIPELINE_STATUSES
from repositories.conferences import ConferenceSessionRepository from repositories.conferences import ConferenceSessionRepository
@@ -104,12 +106,12 @@ _PIPELINE_GAUGE_TIMEOUT_S = 2.0
async def _refresh_pipeline_sessions_gauge(session: AsyncSession) -> None: async def _refresh_pipeline_sessions_gauge(session: AsyncSession) -> None:
"""Пересчитать `vidconf_pipeline_sessions` по всем статусам `pipeline_status`. """Пересчитать `vidconf_pipeline_sessions` по всем статусам `pipeline_status`.
Ходит через основной пул (`session` — из `Depends(get_session)`, см. `session` — из `Depends(get_metrics_session)` (отдельный от основного
докстринг модуля про ограничения тестового харнесса). Если пул занят пула движок, см. докстринг модуля и `core/db.py`). Таймаут и try/except
или БД недоступна, запрос не должен держать весь `/metrics` — таймаут ниже — вторая линия обороны на случай, если недоступна сама БД (а не
короче `db_pool_timeout`, ошибка гасится, gauge остаётся на прежнем только основной пул): запрос не должен держать весь `/metrics`, gauge
значении (не обнуляется — обнулять его при недоступности БД так же остаётся на прежнем значении (не обнуляется — обнулять его при
неверно, как считать сеансы пропавшими). недоступности БД так же неверно, как считать сеансы пропавшими).
""" """
try: try:
counts = await asyncio.wait_for( counts = await asyncio.wait_for(
@@ -249,7 +251,7 @@ def _refresh_host_info_gauge() -> None:
@router.get("/metrics") @router.get("/metrics")
async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Response: async def metrics_endpoint(session: AsyncSession = Depends(get_metrics_session)) -> Response:
"""Отдать метрики Prometheus в формате text exposition. """Отдать метрики Prometheus в формате text exposition.
Gauge'и пересчитываются прямо здесь (а не по расписанию/периодическим Gauge'и пересчитываются прямо здесь (а не по расписанию/периодическим
@@ -259,10 +261,10 @@ async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Resp
редко (обычно раз в 1530с), нагрузка пренебрежимо мала. редко (обычно раз в 1530с), нагрузка пренебрежимо мала.
Порядок важен: метрики о состоянии основного пула БД (`_refresh_db_up_gauge`, Порядок важен: метрики о состоянии основного пула БД (`_refresh_db_up_gauge`,
`_refresh_db_pool_gauges`) считаются первыми и не зависят от самого пула `_refresh_db_pool_gauges`) считаются первыми — они гарантированно попадут в
(см. докстринг модуля) — они гарантированно попадут в ответ, даже если ответ, даже если следующая за ними `_refresh_pipeline_sessions_gauge`
следующий за ними `_refresh_pipeline_sessions_gauge` (основной пул) зависнет (отдельный движок, но всё ещё сама БД) зависнет или упадёт (таймаут/
или упадёт под нагрузкой. try-except внутри неё гасят это, не роняя остальные метрики).
""" """
await _refresh_db_up_gauge() await _refresh_db_up_gauge()
_refresh_db_pool_gauges() _refresh_db_pool_gauges()

View File

@@ -65,6 +65,38 @@ async def check_db_up() -> bool:
return True return True
# --- Сессия для метрик, читающих данные (не только «жив/мёртв»), вне основного
# пула (сессия 37, доделка session 32/33 — см. докстринг `api/metrics.py`) ----
#
# `check_db_up` выше обходится голым соединением ("SELECT 1"), но
# `vidconf_pipeline_sessions` нужна полноценная ORM-сессия (репозиторий,
# группировка по статусу) — `NullPool`, как и у `_probe_engine`: каждый вызов
# открывает новое соединение и сразу закрывает его, бюджет основного пула
# (`engine.pool`) не расходуется. `async_sessionmaker` — тот же паттерн, что
# `async_session_maker` выше, просто на другом движке.
_metrics_probe_engine: AsyncEngine = create_async_engine(
settings.database_url,
poolclass=NullPool,
connect_args={"timeout": settings.db_probe_timeout_s},
)
_metrics_session_maker = async_sessionmaker(_metrics_probe_engine, expire_on_commit=False)
async def get_metrics_session() -> AsyncGenerator[AsyncSession, None]:
"""Зависимость FastAPI для метрик, которым нужна БД, но не основной пул.
В отличие от `get_session()` (основной пул `engine.pool`, конкурирует за
те же 10+10×воркеров соединений, что и API-запросы), сессия здесь открыта
на `_metrics_probe_engine` — исчерпание основного пула эту метрику не
заденет, как и `vidconf_db_up`/`vidconf_db_pool_*`. Тестовый харнесс
(`tests/conftest.py`) подменяет и её на savepoint-сессию теста — так же,
как `get_session` — иначе тест `vidconf_pipeline_sessions` не видел бы
данные, ещё не закоммиченные за пределы savepoint.
"""
async with _metrics_session_maker() as session:
yield session
def db_pool_checked_out() -> int: def db_pool_checked_out() -> int:
"""Число соединений основного пула, занятых прямо сейчас — без обращения к БД. """Число соединений основного пула, занятых прямо сейчас — без обращения к БД.

View File

@@ -51,7 +51,9 @@ class User(Base):
# был выключен либо пользователь зарегистрирован до появления этой # был выключен либо пользователь зарегистрирован до появления этой
# настройки); вход таким пользователям не блокируется. # настройки); вход таким пользователям не блокируется.
consent_version: Mapped[int | None] = mapped_column(Integer, nullable=True) consent_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
consent_given_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) consent_given_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )

View File

@@ -61,6 +61,9 @@ ignore = ["B008"] # FastAPI's `Depends(...)` default-argument pattern is idioma
# большинство из них игнорирует) — длина строки не показатель качества здесь, # большинство из них игнорирует) — длина строки не показатель качества здесь,
# оборачивать вёрстку ради line-length бессмысленно. # оборачивать вёрстку ради line-length бессмысленно.
"services/email_templates.py" = ["E501"] "services/email_templates.py" = ["E501"]
# Дефолтный текст регламента обработки персональных данных — прозаический
# шаблон документа, не код (см. докстринг модуля).
"services/consent_policy_text.py" = ["E501"]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]
# `workers/` — соседний пакет монорепо (см. `pythonpath` в # `workers/` — соседний пакет монорепо (см. `pythonpath` в

View File

@@ -0,0 +1,35 @@
"""Дефолтный текст регламента обработки персональных данных (ключ `instance_settings.consent_policy`).
Вынесен из `services/instance_settings.py` в отдельный модуль ради `E501`:
это прозаический шаблон документа, а не код, оборачивать его строки ради
лимита длины строки бессмысленно — так же, как `email_templates.py`
(см. `[tool.ruff.lint.per-file-ignores]` в `pyproject.toml`).
"""
DEFAULT_CONSENT_POLICY_TEXT = """Это типовой шаблон для предварительной демонстрации. Текст не проходил проверку юриста и не может использоваться как окончательная редакция без такой проверки. Администратор обязан заменить плейсхолдеры в квадратных скобках и, при необходимости, весь текст — под свою организацию и юрисдикцию.
1. Оператор персональных данных
Оператором персональных данных, обрабатываемых при использовании сервиса [название сервиса], является: [полное наименование организации], [ОГРН/ИНН], адрес места нахождения: [адрес]. Контакты по вопросам обработки персональных данных: [email], [телефон].
2. Правовое основание обработки
Обработка персональных данных осуществляется в соответствии с Конституцией Российской Федерации, Федеральным законом от 27.07.2006 № 152-ФЗ «О персональных данных» и принятыми в соответствии с ним нормативными правовыми актами, на основании согласия субъекта персональных данных (статья 9 Федерального закона № 152-ФЗ).
3. Состав и цели обработки
При регистрации в сервисе обрабатываются следующие персональные данные: адрес электронной почты, имя и фамилия (или иное указанное пользователем имя), пароль (в виде хеша) [дополнить при необходимости].
Цели обработки: [указать цели — например: создание учётной записи, идентификация пользователя, обеспечение доступа к видеоконференциям, направление служебных уведомлений].
4. Срок обработки и хранения
Персональные данные хранятся в течение [указать срок — например: срока действия учётной записи и установленного законом срока после её удаления] либо до отзыва согласия, если это не противоречит требованиям законодательства.
5. Действия с персональными данными
В отношении персональных данных совершаются следующие действия: сбор, запись, систематизация, накопление, хранение, уточнение, извлечение, использование, передача (в объёме, необходимом для функционирования сервиса), обезличивание, блокирование, удаление, уничтожение.
6. Права субъекта персональных данных
Субъект персональных данных вправе получать информацию о том, как обрабатываются его персональные данные, требовать их уточнения, блокирования или уничтожения, а также отозвать согласие на обработку, обратившись по контактам, указанным в разделе 1.
7. Согласие
Регистрируясь в сервисе, пользователь подтверждает, что ознакомлен с настоящим регламентом и даёт согласие на обработку своих персональных данных на условиях, изложенных выше."""
"""Дефолтный текст регламента (ключ `consent_policy`) — согласован с оператором
до встраивания в код (сессия 30). Шаблон с плейсхолдерами в квадратных
скобках, без указания конкретной организации — администратор обязан
заменить их под свою организацию перед вводом в эксплуатацию."""

View File

@@ -42,6 +42,7 @@ from core.plugins.config import (
from models.instance_setting import InstanceSetting from models.instance_setting import InstanceSetting
from services.ai_levels import detect_ai_levels from services.ai_levels import detect_ai_levels
from services.ai_tiers import TIERS from services.ai_tiers import TIERS
from services.consent_policy_text import DEFAULT_CONSENT_POLICY_TEXT
_KEY_TRANSCRIBER = "transcriber" _KEY_TRANSCRIBER = "transcriber"
_KEY_SUMMARIZER = "summarizer" _KEY_SUMMARIZER = "summarizer"
@@ -69,6 +70,26 @@ BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
`scripts/apply_preset_settings.py`, чтобы не дублировать список строковых `scripts/apply_preset_settings.py`, чтобы не дублировать список строковых
имён ключей `instance_settings`.""" имён ключей `instance_settings`."""
MANAGED_KEYS: tuple[str, ...] = (
_KEY_TRANSCRIBER,
_KEY_SUMMARIZER,
_KEY_CHAT,
_KEY_HAND_QUEUE,
_KEY_AI_LEVEL,
_KEY_SUMMARY_RECIPIENTS,
_KEY_DISPLAY_TIMEZONE,
_KEY_REGISTRATION_TEAM_CHOICE,
_KEY_REGISTRATION_EMAIL_DOMAIN,
_KEY_CONTACT_EMAIL,
_KEY_MEDIA_LIMITS,
_KEY_CONSENT_POLICY,
_KEY_DEVICE_CHECK,
_KEY_VIRTUAL_BACKGROUND,
)
"""Все ключи, которыми управляет `InstanceSettingsService` — единый источник истины
для тестовой изоляции от состояния `instance_settings` в общей dev-БД
(`tests/conftest.py::clean_instance_settings`)."""
_DEFAULT_AI_LEVEL_VALUE = {"level": "min"} _DEFAULT_AI_LEVEL_VALUE = {"level": "min"}
_DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"} _DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"}
_DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"} _DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"}
@@ -87,34 +108,6 @@ _DEFAULT_VIRTUAL_BACKGROUND_VALUE = {"enabled": False}
нейросеть сегментации на клиенте, и включать её самим фактом обновления у тех, нейросеть сегментации на клиенте, и включать её самим фактом обновления у тех,
кто ничего не просил, нельзя (то же правило, что и у остальных модулей).""" кто ничего не просил, нельзя (то же правило, что и у остальных модулей)."""
DEFAULT_CONSENT_POLICY_TEXT = """Это типовой шаблон для предварительной демонстрации. Текст не проходил проверку юриста и не может использоваться как окончательная редакция без такой проверки. Администратор обязан заменить плейсхолдеры в квадратных скобках и, при необходимости, весь текст — под свою организацию и юрисдикцию.
1. Оператор персональных данных
Оператором персональных данных, обрабатываемых при использовании сервиса [название сервиса], является: [полное наименование организации], [ОГРН/ИНН], адрес места нахождения: [адрес]. Контакты по вопросам обработки персональных данных: [email], [телефон].
2. Правовое основание обработки
Обработка персональных данных осуществляется в соответствии с Конституцией Российской Федерации, Федеральным законом от 27.07.2006 № 152-ФЗ «О персональных данных» и принятыми в соответствии с ним нормативными правовыми актами, на основании согласия субъекта персональных данных (статья 9 Федерального закона № 152-ФЗ).
3. Состав и цели обработки
При регистрации в сервисе обрабатываются следующие персональные данные: адрес электронной почты, имя и фамилия (или иное указанное пользователем имя), пароль (в виде хеша) [дополнить при необходимости].
Цели обработки: [указать цели — например: создание учётной записи, идентификация пользователя, обеспечение доступа к видеоконференциям, направление служебных уведомлений].
4. Срок обработки и хранения
Персональные данные хранятся в течение [указать срок — например: срока действия учётной записи и установленного законом срока после её удаления] либо до отзыва согласия, если это не противоречит требованиям законодательства.
5. Действия с персональными данными
В отношении персональных данных совершаются следующие действия: сбор, запись, систематизация, накопление, хранение, уточнение, извлечение, использование, передача (в объёме, необходимом для функционирования сервиса), обезличивание, блокирование, удаление, уничтожение.
6. Права субъекта персональных данных
Субъект персональных данных вправе получать информацию о том, как обрабатываются его персональные данные, требовать их уточнения, блокирования или уничтожения, а также отозвать согласие на обработку, обратившись по контактам, указанным в разделе 1.
7. Согласие
Регистрируясь в сервисе, пользователь подтверждает, что ознакомлен с настоящим регламентом и даёт согласие на обработку своих персональных данных на условиях, изложенных выше."""
"""Дефолтный текст регламента (ключ `consent_policy`) — согласован с оператором
до встраивания в код (сессия 30). Шаблон с плейсхолдерами в квадратных
скобках, без указания конкретной организации — администратор обязан
заменить их под свою организацию перед вводом в эксплуатацию."""
_DEFAULT_CONSENT_POLICY_VALUE: dict[str, Any] = { _DEFAULT_CONSENT_POLICY_VALUE: dict[str, Any] = {
"enabled": False, "enabled": False,
"text": DEFAULT_CONSENT_POLICY_TEXT, "text": DEFAULT_CONSENT_POLICY_TEXT,
@@ -435,7 +428,9 @@ class InstanceSettingsService:
if patch.consent_required is not None or patch.consent_policy_text is not None: if patch.consent_required is not None or patch.consent_policy_text is not None:
consent_required = ( consent_required = (
patch.consent_required if patch.consent_required is not None else cfg.consent_required patch.consent_required
if patch.consent_required is not None
else cfg.consent_required
) )
consent_text = ( consent_text = (
patch.consent_policy_text.strip() patch.consent_policy_text.strip()

View File

@@ -21,10 +21,11 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession
from starlette.types import Message, Scope from starlette.types import Message, Scope
from core.db import engine, get_session from core.db import engine, get_metrics_session, get_session
from core.redis import redis_client from core.redis import redis_client
from main import create_app from main import create_app
from models.instance_setting import InstanceSetting from models.instance_setting import InstanceSetting
from services.instance_settings import MANAGED_KEYS
@pytest_asyncio.fixture(autouse=True) @pytest_asyncio.fixture(autouse=True)
@@ -88,6 +89,37 @@ async def _load_committed_instance_settings() -> dict[str, Any]:
return {key: value for key, value in result.all()} return {key: value for key, value in result.all()}
@pytest_asyncio.fixture
async def clean_instance_settings(db_session: AsyncSession) -> AsyncGenerator[None, None]:
"""Изолировать тест от текущего состояния управляемых ключей `instance_settings` в
общей dev-БД (тоггл чата, поднятия руки и т.п. — живые настройки разработчика, а не
тестовые данные).
Удаляет строки `MANAGED_KEYS` внутри savepoint-транзакции теста (`db_session`) —
последующее чтение конфигурации (`InstanceSettingsService.get`/`load_effective_config`)
падает на дефолты pydantic-моделей (например, `ChatConfig.enabled == True`), одинаковые
независимо от того, что реально сохранено в dev-БД в момент прогона. Savepoint
откатывается в `db_connection` по завершении теста — восстанавливать исходные строки
вручную не нужно, в отличие от `_preserve_instance_settings` (та фикстура страхует от
записи МИМО savepoint, эта — от чтения ИЗ него состояния, унаследованного от dev-БД).
НЕ autouse и намеренно: `DELETE` внутри savepoint держит Postgres-блокировку на
строке до конца ВНЕШНЕЙ транзакции теста (`db_connection`, откатывается только в
teardown) — savepoint её не освобождает раньше срока. Тесты, которые параллельно
внутри СЕБЯ же пишут в те же ключи через ОТДЕЛЬНОЕ реальное подключение
(`async_session_maker`/`engine.connect()` — см. `test_transcription_disabled_setting_
stops_run_pipeline`, `clean_bootstrap_managed_keys`), заблокировались бы сами на себе,
если бы эта фикстура применялась к ним автоматически (наступили при первой попытке
сделать её autouse — само-дедлок, тест висел до ручного убийства процесса). Поэтому
запрашивать явно, только в тестах, где именно ОНА обеспечивает изоляцию (WS чата/
очереди рук и т.п.), а не там, где тест сам управляет состоянием через реальные
коммиты.
"""
await db_session.execute(delete(InstanceSetting).where(InstanceSetting.key.in_(MANAGED_KEYS)))
await db_session.commit()
yield
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def db_connection() -> AsyncGenerator[AsyncConnection, None]: async def db_connection() -> AsyncGenerator[AsyncConnection, None]:
async with engine.connect() as connection: async with engine.connect() as connection:
@@ -113,13 +145,21 @@ async def db_session(db_connection: AsyncConnection) -> AsyncGenerator[AsyncSess
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def app(db_session: AsyncSession) -> AsyncGenerator[FastAPI, None]: async def app(db_session: AsyncSession) -> AsyncGenerator[FastAPI, None]:
"""Экземпляр FastAPI-приложения с `get_session`, подменённым на тестовую (savepoint) сессию.""" """Экземпляр FastAPI-приложения с `get_session`/`get_metrics_session`,
подменёнными на тестовую (savepoint) сессию.
`get_metrics_session` (сессия 37, `core/db.py`) в проде — отдельный от
основного пула движок, но в тестах должен указывать на ТУ ЖЕ savepoint-
сессию, что и `get_session` — иначе `/metrics` не видел бы данные теста,
ещё не закоммиченные за пределы savepoint (см. `test_metrics_api.py`).
"""
application = create_app() application = create_app()
async def _override_get_session() -> AsyncGenerator[AsyncSession, None]: async def _override_get_session() -> AsyncGenerator[AsyncSession, None]:
yield db_session yield db_session
application.dependency_overrides[get_session] = _override_get_session application.dependency_overrides[get_session] = _override_get_session
application.dependency_overrides[get_metrics_session] = _override_get_session
yield application yield application

View File

@@ -529,7 +529,11 @@ async def test_register_without_consent_when_required_returns_400(
response = await client.post( response = await client.post(
"/api/v1/auth/register", "/api/v1/auth/register",
json={"email": "no-consent@example.com", "name_user": "No Consent", "password": "supersecret1"}, json={
"email": "no-consent@example.com",
"name_user": "No Consent",
"password": "supersecret1",
},
) )
assert response.status_code == 400 assert response.status_code == 400
assert response.json()["detail"] == "consent_required" assert response.json()["detail"] == "consent_required"
@@ -571,7 +575,11 @@ async def test_register_without_consent_when_module_disabled_succeeds_and_leaves
галочки, `consent_version`/`consent_given_at` остаются `NULL`.""" галочки, `consent_version`/`consent_given_at` остаются `NULL`."""
response = await client.post( response = await client.post(
"/api/v1/auth/register", "/api/v1/auth/register",
json={"email": "consent-disabled@example.com", "name_user": "Consent Disabled", "password": "supersecret1"}, json={
"email": "consent-disabled@example.com",
"name_user": "Consent Disabled",
"password": "supersecret1",
},
) )
assert response.status_code == 201, response.text assert response.status_code == 201, response.text

View File

@@ -7,13 +7,14 @@ websocket-тестов поверх нашей savepoint-сессии БД (см
""" """
import uuid import uuid
from collections.abc import Callable from collections.abc import AsyncGenerator, Callable
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
import httpx import httpx
import jwt import jwt
import pytest import pytest
import pytest_asyncio
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy import select, text from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -37,6 +38,16 @@ from tests.conftest import ASGIWebSocketSession
WSFactory = Callable[[str], ASGIWebSocketSession] WSFactory = Callable[[str], ASGIWebSocketSession]
@pytest_asyncio.fixture(autouse=True)
async def _isolated_instance_settings(clean_instance_settings: None) -> AsyncGenerator[None, None]:
"""Autouse только в этом модуле — изолирует тесты от состояния `chat`/`hand_queue`
(и остальных управляемых ключей) в общей dev-БД (см. `tests.conftest.clean_instance_settings`,
почему не сделана глобально autouse). Безопасно именно здесь: ни один тест файла не
открывает отдельного подключения к `instance_settings` — только `db_session`/`client`.
"""
yield
# --- Хелперы --------------------------------------------------------------- # --- Хелперы ---------------------------------------------------------------

View File

@@ -5,10 +5,11 @@
""" """
import uuid import uuid
from collections.abc import Callable from collections.abc import AsyncGenerator, Callable
from typing import Any from typing import Any
import httpx import httpx
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from core.security import create_access_token, hash_password from core.security import create_access_token, hash_password
@@ -23,6 +24,13 @@ from tests.conftest import ASGIWebSocketSession
WSFactory = Callable[[str], ASGIWebSocketSession] WSFactory = Callable[[str], ASGIWebSocketSession]
@pytest_asyncio.fixture(autouse=True)
async def _isolated_instance_settings(clean_instance_settings: None) -> AsyncGenerator[None, None]:
"""Autouse только в этом модуле — см. `tests.test_chat_ws._isolated_instance_settings`
и `tests.conftest.clean_instance_settings` (почему не глобальный autouse)."""
yield
# --- Хелперы (см. tests/test_chat_ws.py) ------------------------------------ # --- Хелперы (см. tests/test_chat_ws.py) ------------------------------------

View File

@@ -12,15 +12,12 @@
видеть реально закоммиченную строку (см. docstring `test_pipeline.py`), видеть реально закоммиченную строку (см. docstring `test_pipeline.py`),
поэтому там запись делается через отдельное подключение к `core.db.engine`. поэтому там запись делается через отдельное подключение к `core.db.engine`.
Тесты, которым нужен «чистый стол» по управляемым ключам `instance_settings` «Чистый стол» по управляемым ключам `instance_settings` обеспечивает общая (НЕ
(бутстрап дефолтов, дефолтные значения после патча), используют фикстуру autouse — см. её докстринг про само-дедлок с тестами на реальных подключениях)
`clean_instance_settings` — она сохраняет текущие строки этих ключей в фикстура `tests.conftest.clean_instance_settings` — тесты, которым нужен чистый
рамках транзакции теста и восстанавливает их после (не `TRUNCATE`): в общей стол, запрашивают её явно параметром. Тест с `run_pipeline_async` пишет через
dev-БД эти строки могут быть легитимными данными разработчика, тест не отдельное подключение (мимо savepoint) и её не запрашивает — там сохранение/
должен от них зависеть, но и не должен их безвозвратно стирать. Тест с восстановление сделано вручную через реальный коннекшн, см. его докстринг.
`run_pipeline_async` не может использовать эту фикстуру (пишет через
отдельное подключение) — там то же сохранение/восстановление сделано вручную
через реальный коннекшн.
""" """
import uuid import uuid
@@ -61,24 +58,6 @@ from workers.tasks.pipeline import run_pipeline_async
PLUGINS_YAML = "../config/plugins.yaml" PLUGINS_YAML = "../config/plugins.yaml"
NOW = datetime.now(UTC) NOW = datetime.now(UTC)
# Все ключи, которыми управляет `InstanceSettingsService` (см. `_KEY_*` там же).
_MANAGED_KEYS = (
"transcriber",
"summarizer",
"chat",
"hand_queue",
"ai_level",
"summary_recipients",
"display_timezone",
"registration_team_choice",
"registration_email_domain",
"contact_email",
"media_limits",
"consent_policy",
"device_check",
"virtual_background",
)
class _FakeTask: class _FakeTask:
"""Минимальная заглушка bound-задачи Celery (см. `test_pipeline.py`).""" """Минимальная заглушка bound-задачи Celery (см. `test_pipeline.py`)."""
@@ -87,32 +66,6 @@ class _FakeTask:
self.retry = MagicMock() self.retry = MagicMock()
@pytest_asyncio.fixture
async def clean_instance_settings(db_session: AsyncSession) -> AsyncGenerator[None, None]:
"""Изолировать тест от уже существующих строк управляемых ключей `instance_settings`.
Сохраняет текущие значения (если есть) в рамках `db_session` (savepoint,
никогда не коммитится в реальную БД — см. `conftest.py`), удаляет их,
отдаёт управление тесту, затем восстанавливает исходные значения —
точечно, только эти ключи, не `TRUNCATE`.
"""
result = await db_session.execute(
select(InstanceSetting).where(InstanceSetting.key.in_(_MANAGED_KEYS))
)
saved: dict[str, Any] = {row.key: row.value for row in result.scalars().all()}
await db_session.execute(delete(InstanceSetting).where(InstanceSetting.key.in_(_MANAGED_KEYS)))
await db_session.commit()
try:
yield
finally:
await db_session.execute(
delete(InstanceSetting).where(InstanceSetting.key.in_(_MANAGED_KEYS))
)
for key, value in saved.items():
db_session.add(InstanceSetting(key=key, value=value))
await db_session.commit()
async def test_ensure_bootstrapped_imports_yaml_defaults( async def test_ensure_bootstrapped_imports_yaml_defaults(
db_session: AsyncSession, clean_instance_settings: None db_session: AsyncSession, clean_instance_settings: None
) -> None: ) -> None:

View File

@@ -69,7 +69,12 @@ services:
env_file: env_file:
- ../.env - ../.env
environment: environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf} # Собран из POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB с жёстким хостом
# `postgres` (сессия 37) — так же, как REDIS_URL ниже, а не читается из
# .env.DATABASE_URL напрямую: тот в корневом .env указывает на `localhost`
# (для запуска backend на хосте вне контейнера, см. CONTEXT §2.1.0), а
# `localhost` внутри контейнера — это сам контейнер, не соседний postgres.
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-vidconf}:${POSTGRES_PASSWORD:-vidconf}@postgres:5432/${POSTGRES_DB:-vidconf}
REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0 REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml} PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?LIVEKIT_API_KEY не задан в .env} LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?LIVEKIT_API_KEY не задан в .env}
@@ -89,7 +94,7 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media} MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение # Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health. # из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.36} VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.37}
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан # Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение, # на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
# проверьте бюджет соединений с БД: каждый воркер держит свой пул # проверьте бюджет соединений с БД: каждый воркер держит свой пул
@@ -143,7 +148,7 @@ services:
env_file: env_file:
- ../.env - ../.env
environment: environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-vidconf}:${POSTGRES_PASSWORD:-vidconf}@postgres:5432/${POSTGRES_DB:-vidconf}
REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0 REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml} PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
PYTHONPATH: /app PYTHONPATH: /app
@@ -240,7 +245,7 @@ services:
env_file: env_file:
- ../.env - ../.env
environment: environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-vidconf}:${POSTGRES_PASSWORD:-vidconf}@postgres:5432/${POSTGRES_DB:-vidconf}
REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0 REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml} PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
RECORDINGS_DIR: ${RECORDINGS_DIR:-/recordings} RECORDINGS_DIR: ${RECORDINGS_DIR:-/recordings}
@@ -296,7 +301,7 @@ services:
env_file: env_file:
- ../.env - ../.env
environment: environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-vidconf}:${POSTGRES_PASSWORD:-vidconf}@postgres:5432/${POSTGRES_DB:-vidconf}
REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0 REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml} PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
RECORDINGS_DIR: ${RECORDINGS_DIR:-/recordings} RECORDINGS_DIR: ${RECORDINGS_DIR:-/recordings}

View File

@@ -452,8 +452,23 @@ export function RoomPage() {
// dynacast: паблишер прекращает отдавать слои, на которые нет подписчиков. // dynacast: паблишер прекращает отдавать слои, на которые нет подписчиков.
adaptiveStream: true, adaptiveStream: true,
dynacast: true, dynacast: true,
audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined }, // `usePersistentUserChoices` хранит «устройство не выбрано» как литерал
videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined }, // `"default"` (`@livekit/components-core`, `defaultUserChoices`), а не
// пустую строку — `|| undefined` его не отсекает, и в Chrome это давало
// `OverconstrainedError` на первом визите (см. тот же сентинел в
// `useDeviceCheckAccess.ts::openStream`).
audioCaptureDefaults: {
deviceId:
userChoices.audioDeviceId && userChoices.audioDeviceId !== 'default'
? userChoices.audioDeviceId
: undefined,
},
videoCaptureDefaults: {
deviceId:
userChoices.videoDeviceId && userChoices.videoDeviceId !== 'default'
? userChoices.videoDeviceId
: undefined,
},
// Аудиовыход (колонки/наушники/bluetooth) — отдельный персист, не через // Аудиовыход (колонки/наушники/bluetooth) — отдельный персист, не через
// usePersistentUserChoices: LocalUserChoices LiveKit про него не знает // usePersistentUserChoices: LocalUserChoices LiveKit про него не знает
// (см. lib/audioOutputDevice.ts). Читаем один раз при монтировании — // (см. lib/audioOutputDevice.ts). Читаем один раз при монтировании —