Compare commits

...

9 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
6554fa242b release: версия 0.0.36 2026-08-10 13:16:16 +03:00
1089d874af fix(room): замена фона в Safari и Firefox
Проверка поддержки была строже, чем требует библиотека, и молча отрезала два
браузера: кнопки выбора фона не было ни в превью, ни в комнате.

У `@livekit/track-processors` ДВА конвейера обработки кадров: современный
(`MediaStreamTrackProcessor`/`Generator`, только Chrome и производные) и
запасной — рисует кадры в canvas и отдаёт `canvas.captureStream()`. Проверка
требовала именно современный, хотя запасной путь доступен и в Safari, и в
Firefox. Заодно она проверяла `OffscreenCanvas`, но пропускала `VideoFrame`,
`createImageBitmap` и WebGL2, которые библиотеке реально нужны.

Теперь условие буквально повторяет `supportsBackgroundProcessors()` из самой
библиотеки: «умеет считать сегментацию» И «есть хоть какой-то конвейер».
При обновлении пакета сверять с ним.

Пробный WebGL2-контекст (иначе поддержку не определить) создаётся один раз на
жизнь страницы и сразу отпускается через `WEBGL_lose_context`: браузеры
держат ограниченное число живых контекстов.

Проверено: в Firefox 153 детект возвращает «поддерживается» (современного
конвейера нет, запасной есть), в Chrome регрессии нет — процессор
поднимается, трек живой.
2026-08-10 13:16:01 +03:00
18 changed files with 350 additions and 134 deletions

View File

@@ -2,6 +2,11 @@
POSTGRES_USER=vidconf
POSTGRES_PASSWORD=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
# --- Redis ---
@@ -128,7 +133,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.35
VIDCONF_VERSION=0.0.37
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
@@ -190,7 +195,22 @@ GRAFANA_ADMIN_PASSWORD=change-me-grafana
# ссылаются на них (проверено grep'ом при харденинге репозитория). Оставлены
# здесь только для полноты покрытия реальных ключей сервера; вычистить или
# начать использовать — по итогам Сессии 2 (bug hunt). ---
MEDIA_URL=http://localhost/media/
VITE_API_URL=http://localhost
VITE_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

1
.gitignore vendored
View File

@@ -61,3 +61,4 @@ frontend/public/mediapipe/wasm/
# Рабочая страница замера CPU (сессия 35), в репозиторий не идёт.
frontend/bg-bench.html
frontend/bg-support.html

View File

@@ -3,6 +3,53 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](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.35: замена фона не появлялась в Safari и Firefox.
### Исправлено
- Проверка поддержки замены фона была строже, чем требует библиотека, и
молча отрезала два браузера — кнопки выбора фона не было ни в превью, ни
в комнате. У библиотеки два конвейера обработки кадров: современный
(только Chrome и производные) и запасной через `canvas.captureStream()`,
который работает и в Safari, и в Firefox; проверка требовала именно
современный. Теперь условие буквально повторяет проверку самой библиотеки.
## [0.0.35] — 2026-08-10
Замена фона видео на картинку — отключаемый модуль (по умолчанию выключен).

View File

@@ -1 +1 @@
0.0.35
0.0.37

View File

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

View File

@@ -65,6 +65,38 @@ async def check_db_up() -> bool:
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:
"""Число соединений основного пула, занятых прямо сейчас — без обращения к БД.

View File

@@ -51,7 +51,9 @@ class User(Base):
# был выключен либо пользователь зарегистрирован до появления этой
# настройки); вход таким пользователям не блокируется.
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(
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 бессмысленно.
"services/email_templates.py" = ["E501"]
# Дефолтный текст регламента обработки персональных данных — прозаический
# шаблон документа, не код (см. докстринг модуля).
"services/consent_policy_text.py" = ["E501"]
[tool.ruff.lint.isort]
# `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 services.ai_levels import detect_ai_levels
from services.ai_tiers import TIERS
from services.consent_policy_text import DEFAULT_CONSENT_POLICY_TEXT
_KEY_TRANSCRIBER = "transcriber"
_KEY_SUMMARIZER = "summarizer"
@@ -69,6 +70,26 @@ BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
`scripts/apply_preset_settings.py`, чтобы не дублировать список строковых
имён ключей `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_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"}
_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] = {
"enabled": False,
"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:
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 = (
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 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 main import create_app
from models.instance_setting import InstanceSetting
from services.instance_settings import MANAGED_KEYS
@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()}
@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
async def db_connection() -> AsyncGenerator[AsyncConnection, None]:
async with engine.connect() as connection:
@@ -113,13 +145,21 @@ async def db_session(db_connection: AsyncConnection) -> AsyncGenerator[AsyncSess
@pytest_asyncio.fixture
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()
async def _override_get_session() -> AsyncGenerator[AsyncSession, None]:
yield db_session
application.dependency_overrides[get_session] = _override_get_session
application.dependency_overrides[get_metrics_session] = _override_get_session
yield application

View File

@@ -529,7 +529,11 @@ async def test_register_without_consent_when_required_returns_400(
response = await client.post(
"/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.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`."""
response = await client.post(
"/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

View File

@@ -7,13 +7,14 @@ websocket-тестов поверх нашей savepoint-сессии БД (см
"""
import uuid
from collections.abc import Callable
from collections.abc import AsyncGenerator, Callable
from datetime import UTC, datetime
from typing import Any
import httpx
import jwt
import pytest
import pytest_asyncio
from pydantic import ValidationError
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -37,6 +38,16 @@ from tests.conftest import 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
from collections.abc import Callable
from collections.abc import AsyncGenerator, Callable
from typing import Any
import httpx
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import create_access_token, hash_password
@@ -23,6 +24,13 @@ from tests.conftest import 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) ------------------------------------

View File

@@ -12,15 +12,12 @@
видеть реально закоммиченную строку (см. docstring `test_pipeline.py`),
поэтому там запись делается через отдельное подключение к `core.db.engine`.
Тесты, которым нужен «чистый стол» по управляемым ключам `instance_settings`
(бутстрап дефолтов, дефолтные значения после патча), используют фикстуру
`clean_instance_settings` — она сохраняет текущие строки этих ключей в
рамках транзакции теста и восстанавливает их после (не `TRUNCATE`): в общей
dev-БД эти строки могут быть легитимными данными разработчика, тест не
должен от них зависеть, но и не должен их безвозвратно стирать. Тест с
`run_pipeline_async` не может использовать эту фикстуру (пишет через
отдельное подключение) — там то же сохранение/восстановление сделано вручную
через реальный коннекшн.
«Чистый стол» по управляемым ключам `instance_settings` обеспечивает общая (НЕ
autouse — см. её докстринг про само-дедлок с тестами на реальных подключениях)
фикстура `tests.conftest.clean_instance_settings` — тесты, которым нужен чистый
стол, запрашивают её явно параметром. Тест с `run_pipeline_async` пишет через
отдельное подключение (мимо savepoint) и её не запрашивает — там сохранение/
восстановление сделано вручную через реальный коннекшн, см. его докстринг.
"""
import uuid
@@ -61,24 +58,6 @@ from workers.tasks.pipeline import run_pipeline_async
PLUGINS_YAML = "../config/plugins.yaml"
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:
"""Минимальная заглушка bound-задачи Celery (см. `test_pipeline.py`)."""
@@ -87,32 +66,6 @@ class _FakeTask:
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(
db_session: AsyncSession, clean_instance_settings: None
) -> None:

View File

@@ -69,7 +69,12 @@ services:
env_file:
- ../.env
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
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?LIVEKIT_API_KEY не задан в .env}
@@ -89,7 +94,7 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.35}
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.37}
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
@@ -143,7 +148,7 @@ services:
env_file:
- ../.env
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
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
PYTHONPATH: /app
@@ -240,7 +245,7 @@ services:
env_file:
- ../.env
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
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
RECORDINGS_DIR: ${RECORDINGS_DIR:-/recordings}
@@ -296,7 +301,7 @@ services:
env_file:
- ../.env
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
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
RECORDINGS_DIR: ${RECORDINGS_DIR:-/recordings}

View File

@@ -98,21 +98,60 @@ export function resolveBackgroundUrl(
}
/**
* Поддерживает ли браузер сегментацию вообще.
* Поддерживает ли браузер замену фона.
*
* Проверяется по наличию API конвейера обработки кадров (Insertable Streams /
* `MediaStreamTrackProcessor`) — их нет, например, в Firefox. Проверка
* СИНХРОННАЯ и намеренно не трогает саму библиотеку: она нужна до её загрузки,
* чтобы не показывать кнопку, которая всё равно не сработает, и не тянуть
* мегабайты впустую.
* Проверка СИНХРОННАЯ и намеренно не трогает саму библиотеку: решение нужно
* ДО её загрузки, чтобы не показывать кнопку, которая не сработает, и не
* тянуть мегабайты впустую. Поэтому здесь буквально повторено условие
* `supportsBackgroundProcessors()` из `@livekit/track-processors` — при
* обновлении пакета сверять с ним.
*
* ⚠️ Конвейеров у библиотеки ДВА, и требовать современный нельзя:
* - современный (`MediaStreamTrackProcessor`/`Generator`, Insertable Streams)
* есть только в Chrome и производных;
* - запасной рисует кадры в canvas и отдаёт `canvas.captureStream()` — он
* работает в Safari и Firefox.
*
* Первая версия (0.0.35) требовала именно современный конвейер — и фича
* молча отсутствовала в Safari и Firefox, хотя запасной путь там доступен.
* Условие ниже — «умеет считать сегментацию» И «есть хоть какой-то конвейер».
*/
export function isVirtualBackgroundSupported(): boolean {
if (typeof window === 'undefined') return false
return (
'MediaStreamTrackProcessor' in window &&
'MediaStreamTrackGenerator' in window &&
typeof OffscreenCanvas !== 'undefined'
)
return canRunSegmentation() && hasAnyFramePipeline()
}
/**
* Условие `BackgroundTransformer.isSupported`: чем библиотека считает маску и
* собирает кадр. WebGL2 проверяется созданием пробного контекста — иначе никак,
* но результат кэшируется на всю жизнь страницы: браузеры держат ограниченное
* число живых WebGL-контекстов, и создавать новый на каждый рендер нельзя.
*/
let segmentationSupport: boolean | null = null
function canRunSegmentation(): boolean {
if (segmentationSupport !== null) return segmentationSupport
const hasApis =
typeof OffscreenCanvas !== 'undefined' &&
typeof VideoFrame !== 'undefined' &&
typeof createImageBitmap !== 'undefined'
if (!hasApis) {
segmentationSupport = false
return false
}
const probe = document.createElement('canvas').getContext('webgl2')
// Пробный контекст сразу отпускаем — он больше не нужен, а слот в лимите
// браузера занимал бы до сборки мусора.
probe?.getExtension('WEBGL_lose_context')?.loseContext()
segmentationSupport = Boolean(probe)
return segmentationSupport
}
/** Условие `ProcessorWrapper.isSupported`: современный конвейер ЛИБО запасной на canvas. */
function hasAnyFramePipeline(): boolean {
const modern = 'MediaStreamTrackProcessor' in window && 'MediaStreamTrackGenerator' in window
const fallback =
typeof HTMLCanvasElement !== 'undefined' && 'captureStream' in HTMLCanvasElement.prototype
return modern || fallback
}
/**

View File

@@ -452,8 +452,23 @@ export function RoomPage() {
// dynacast: паблишер прекращает отдавать слои, на которые нет подписчиков.
adaptiveStream: true,
dynacast: true,
audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined },
videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined },
// `usePersistentUserChoices` хранит «устройство не выбрано» как литерал
// `"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) — отдельный персист, не через
// usePersistentUserChoices: LocalUserChoices LiveKit про него не знает
// (см. lib/audioOutputDevice.ts). Читаем один раз при монтировании —