Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41aca319fc | |||
| 1ab146e4b0 | |||
| 9abe5b5102 | |||
| 6db142d45a | |||
| 1c2a9cd24a | |||
| c4485d43b1 | |||
| 0a13589612 | |||
| 6554fa242b | |||
| 1089d874af |
26
.env.example
26
.env.example
@@ -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.35
|
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
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -61,3 +61,4 @@ frontend/public/mediapipe/wasm/
|
|||||||
|
|
||||||
# Рабочая страница замера CPU (сессия 35), в репозиторий не идёт.
|
# Рабочая страница замера CPU (сессия 35), в репозиторий не идёт.
|
||||||
frontend/bg-bench.html
|
frontend/bg-bench.html
|
||||||
|
frontend/bg-support.html
|
||||||
|
|||||||
47
CHANGELOG.md
47
CHANGELOG.md
@@ -3,6 +3,53 @@
|
|||||||
Формат основан на [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.35: замена фона не появлялась в Safari и Firefox.
|
||||||
|
|
||||||
|
### Исправлено
|
||||||
|
- Проверка поддержки замены фона была строже, чем требует библиотека, и
|
||||||
|
молча отрезала два браузера — кнопки выбора фона не было ни в превью, ни
|
||||||
|
в комнате. У библиотеки два конвейера обработки кадров: современный
|
||||||
|
(только Chrome и производные) и запасной через `canvas.captureStream()`,
|
||||||
|
который работает и в Safari, и в Firefox; проверка требовала именно
|
||||||
|
современный. Теперь условие буквально повторяет проверку самой библиотеки.
|
||||||
|
|
||||||
## [0.0.35] — 2026-08-10
|
## [0.0.35] — 2026-08-10
|
||||||
|
|
||||||
Замена фона видео на картинку — отключаемый модуль (по умолчанию выключен).
|
Замена фона видео на картинку — отключаемый модуль (по умолчанию выключен).
|
||||||
|
|||||||
@@ -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
|
|||||||
редко (обычно раз в 15–30с), нагрузка пренебрежимо мала.
|
редко (обычно раз в 15–30с), нагрузка пренебрежимо мала.
|
||||||
|
|
||||||
Порядок важен: метрики о состоянии основного пула БД (`_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()
|
||||||
|
|||||||
@@ -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:
|
||||||
"""Число соединений основного пула, занятых прямо сейчас — без обращения к БД.
|
"""Число соединений основного пула, занятых прямо сейчас — без обращения к БД.
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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` в
|
||||||
|
|||||||
35
backend/services/consent_policy_text.py
Normal file
35
backend/services/consent_policy_text.py
Normal 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). Шаблон с плейсхолдерами в квадратных
|
||||||
|
скобках, без указания конкретной организации — администратор обязан
|
||||||
|
заменить их под свою организацию перед вводом в эксплуатацию."""
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
# --- Хелперы ---------------------------------------------------------------
|
# --- Хелперы ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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) ------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.35}
|
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}
|
||||||
|
|||||||
@@ -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 {
|
export function isVirtualBackgroundSupported(): boolean {
|
||||||
if (typeof window === 'undefined') return false
|
if (typeof window === 'undefined') return false
|
||||||
return (
|
return canRunSegmentation() && hasAnyFramePipeline()
|
||||||
'MediaStreamTrackProcessor' in window &&
|
}
|
||||||
'MediaStreamTrackGenerator' in window &&
|
|
||||||
typeof OffscreenCanvas !== 'undefined'
|
/**
|
||||||
)
|
* Условие `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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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). Читаем один раз при монтировании —
|
||||||
|
|||||||
Reference in New Issue
Block a user