Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7a01e0087 | |||
| 9fb1f1f7ee | |||
| e2254819c7 | |||
| e71bf0623f | |||
| b12d32dfba | |||
| 359f9fd1f5 | |||
| 41aca319fc | |||
| 1ab146e4b0 | |||
| 9abe5b5102 | |||
| 6db142d45a | |||
| 1c2a9cd24a | |||
| c4485d43b1 | |||
| 0a13589612 |
26
.env.example
26
.env.example
@@ -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.36
|
||||
VIDCONF_VERSION=0.0.39
|
||||
|
||||
# --- Профили 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
|
||||
|
||||
68
CHANGELOG.md
68
CHANGELOG.md
@@ -3,6 +3,74 @@
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
|
||||
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
|
||||
|
||||
## [0.0.39] — 2026-08-11
|
||||
|
||||
### Добавлено
|
||||
- Вкладка «Зайти как гость» на экранах входа и регистрации — ведёт на
|
||||
`/join`, чтобы незалогиненный пользователь мог подключиться по номеру
|
||||
конференции, не выбирая между входом и регистрацией. На узких экранах
|
||||
(375/360px) подпись сокращается до «Гость» (та же схема коротких
|
||||
подписей, что у «Мини-окно»/«Мини» в тулбаре комнаты).
|
||||
|
||||
### Исправлено
|
||||
- Дата и время в прошлом были запрещены сервером для ЛЮБОЙ конференции
|
||||
при создании, включая закреплённые — хотя закреплённая конференция не
|
||||
разовое событие, а постоянная комната-ссылка, и прошедшая дата для неё
|
||||
осмысленна (например, серия «каждый день с такого-то числа», уже
|
||||
наступившего). Запрет прошлого теперь действует только для обычных
|
||||
конференций.
|
||||
- Поле «Длительность, мин» в форме конференции: числовое состояние
|
||||
(`Number(value) || 0`) не давало очистить поле — стирание тут же
|
||||
превращалось обратно в 0, и следующая цифра визуально дописывалась
|
||||
к нему вместо замены. Состояние переведено на строку, плюс выделение
|
||||
содержимого по фокусу.
|
||||
|
||||
## [0.0.38] — 2026-08-11
|
||||
|
||||
### Исправлено
|
||||
- Safari: при выключении виртуального фона в комнате чернело собственное
|
||||
видео (остальные участники видели картинку нормально). Причина —
|
||||
внутренняя охрана livekit-client от мерцания сравнивала `srcObject` с
|
||||
тем же объектом `MediaStream` и из-за этого не давала сработать
|
||||
собственному обходному пути библиотеки для чёрного кадра в Safari.
|
||||
Теперь после остановки процессора фона элементы принудительно
|
||||
переприцепляются (`detach`/`attach`), пересоздавая `MediaStream`.
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -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
|
||||
редко (обычно раз в 15–30с), нагрузка пренебрежимо мала.
|
||||
|
||||
Порядок важен: метрики о состоянии основного пула БД (`_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()
|
||||
|
||||
@@ -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:
|
||||
"""Число соединений основного пула, занятых прямо сейчас — без обращения к БД.
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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` в
|
||||
|
||||
@@ -91,7 +91,17 @@ class ConferenceCreateIn(BaseModel):
|
||||
raise ValueError("closed_conference_requires_password")
|
||||
if self.recurrence is not None and not self.is_pinned:
|
||||
raise ValueError("recurrence_requires_pinned")
|
||||
if self.scheduled_at is not None and self.scheduled_at < datetime.now(UTC) - PAST_TOLERANCE:
|
||||
# Запрет прошлого — только для обычных (не закреплённых) конференций.
|
||||
# Закреплённая конференция — не разовое событие с конкретной датой, а
|
||||
# постоянная комната-ссылка (см. комментарий у `is_pinned` в
|
||||
# `models/conference.py`); `scheduled_at` для неё — не более чем
|
||||
# подсказка в календаре, и прошедшая дата (например, «встречаемся тут
|
||||
# каждый день с такого-то числа», уже наступившего) осмысленна.
|
||||
if (
|
||||
not self.is_pinned
|
||||
and self.scheduled_at is not None
|
||||
and self.scheduled_at < datetime.now(UTC) - PAST_TOLERANCE
|
||||
):
|
||||
raise ValueError("scheduled_at_in_the_past")
|
||||
return self
|
||||
|
||||
|
||||
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 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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# --- Хелперы ---------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -212,6 +212,23 @@ async def test_create_scheduled_in_the_past_returns_422(
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_create_pinned_scheduled_in_the_past_is_allowed(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Закреплённая конференция — не разовое событие, прошлая дата для неё осмысленна."""
|
||||
user = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
past = datetime.now(UTC) - timedelta(hours=1)
|
||||
response = await client.post(
|
||||
"/api/v1/conferences",
|
||||
json={"title": "Pinned past", "scheduled_at": _iso(past), "is_pinned": True},
|
||||
headers=_auth_headers(user),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["is_pinned"] is True
|
||||
|
||||
|
||||
# --- «Мои конференции» ---------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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) ------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.36}
|
||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.39}
|
||||
# Число процессов 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}
|
||||
|
||||
@@ -75,7 +75,16 @@ export function ConferenceFormCard({ conference, onSaved, onDeleted, onCancelEdi
|
||||
const [title, setTitle] = useState(conference?.title ?? '')
|
||||
const [dateStr, setDateStr] = useState(initialSlot.date)
|
||||
const [timeStr, setTimeStr] = useState(initialSlot.time)
|
||||
const [durationMinutes, setDurationMinutes] = useState(conference?.duration_minutes ?? 30)
|
||||
// Строкой, а не числом: контролируемый number-инпут с числовым состоянием
|
||||
// не даёт очистить поле (`Number('') || 0` тут же возвращает 0, значение
|
||||
// "0" вспыхивает обратно) — строка хранит именно то, что ввёл пользователь,
|
||||
// включая пустоту. Вторая половина жалобы («ввод дописывается после 0», а
|
||||
// не заменяет его) — не про хранение, а про то, что при фокусе курсор
|
||||
// просто вставал рядом с текущим значением; чинится `onFocus={select()}`
|
||||
// у самого `<input>` ниже. `durationMinutes` — производное число для
|
||||
// валидации и отправки.
|
||||
const [durationMinutesInput, setDurationMinutesInput] = useState(String(conference?.duration_minutes ?? 30))
|
||||
const durationMinutes = Number(durationMinutesInput) || 0
|
||||
|
||||
const [isClosed, setIsClosed] = useState(conference?.is_closed ?? false)
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -306,8 +315,9 @@ export function ConferenceFormCard({ conference, onSaved, onDeleted, onCancelEdi
|
||||
type="number"
|
||||
min={5}
|
||||
step={5}
|
||||
value={durationMinutes}
|
||||
onChange={(e) => setDurationMinutes(Number(e.target.value) || 0)}
|
||||
value={durationMinutesInput}
|
||||
onChange={(e) => setDurationMinutesInput(e.target.value)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -103,6 +103,22 @@ export function useVirtualBackground(
|
||||
if (!track || !desiredUrl) {
|
||||
if (track && processorRef.current) {
|
||||
await track.stopProcessor()
|
||||
// Safari-обход: `stopProcessor` внутри livekit-client меняет трек
|
||||
// в элементе «на месте», переиспользуя тот же объект `MediaStream`
|
||||
// (внутренний `detach` промахивается мимо обработанного трека,
|
||||
// который реально висел в элементе). Из-за этого собственная
|
||||
// защита библиотеки от мерцания (`srcObject !== mediaStream` в
|
||||
// `attachToElement`) не даёт сработать её же обходному пути для
|
||||
// чёрного кадра в Safari. Пересоздаём `MediaStream` вручную —
|
||||
// detach обнулит `srcObject`, attach создаст новый объект, защита
|
||||
// пропустит штатный `play()`. Элемент остаётся в своём документе,
|
||||
// никуда не переезжает. Копия массива обязательна: `detach`
|
||||
// правит `attachedElements` по ходу перебора. Уберётся, если
|
||||
// библиотека когда-нибудь починит эту защиту.
|
||||
for (const el of [...track.attachedElements]) {
|
||||
track.detach(el)
|
||||
track.attach(el)
|
||||
}
|
||||
await processorRef.current.destroy()
|
||||
processorRef.current = null
|
||||
}
|
||||
|
||||
@@ -70,6 +70,10 @@ export function LoginPage() {
|
||||
<Link to="/register" className="tab" role="tab" aria-selected="false">
|
||||
Регистрация
|
||||
</Link>
|
||||
<Link to="/join" className="tab" role="tab" aria-selected="false">
|
||||
<span className="label-full">Зайти как гость</span>
|
||||
<span className="label-short">Гость</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<form
|
||||
|
||||
@@ -133,6 +133,10 @@ export function RegisterPage() {
|
||||
<span className="tab is-active" role="tab" aria-selected="true">
|
||||
Регистрация
|
||||
</span>
|
||||
<Link to="/join" className="tab" role="tab" aria-selected="false">
|
||||
<span className="label-full">Зайти как гость</span>
|
||||
<span className="label-short">Гость</span>
|
||||
</Link>
|
||||
</div>
|
||||
<h2 className="screen-title">Создать аккаунт</h2>
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
|
||||
@@ -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). Читаем один раз при монтировании —
|
||||
|
||||
@@ -104,6 +104,11 @@
|
||||
}
|
||||
.tab.is-active { background: var(--color-surface); color: var(--color-ink-700); font-weight: 600; box-shadow: var(--shadow-sm); }
|
||||
|
||||
/* Короткая подпись вкладки «Зайти как гость» — на узком экране (см. @media
|
||||
ниже) длинный вариант переносится на 2 строки и делает вкладку выше
|
||||
соседних; та же схема, что у «Мини-окно»/«Мини» в RoomToolbar.tsx/room.css. */
|
||||
.tab .label-short { display: none; }
|
||||
|
||||
.field { margin-bottom: var(--space-5); }
|
||||
.field label { display: block; font: var(--text-body); font-weight: 600; color: var(--color-ink-900); margin-bottom: var(--space-2); }
|
||||
.input-wrap { position: relative; display: flex; align-items: center; }
|
||||
@@ -289,4 +294,13 @@
|
||||
.stat-glass { flex-basis: 100%; }
|
||||
|
||||
.form-panel { padding: var(--space-6) var(--space-4) var(--space-10); }
|
||||
|
||||
/* Три вкладки (Вход/Регистрация/Зайти как гость) на ширину 375/360px в
|
||||
исходный `width: fit-content` не помещаются — растягиваем группу на
|
||||
всю ширину блока и даём каждой вкладке одинаковую долю вместо
|
||||
ужимания по контенту, иначе текст «Регистрация» переносится. */
|
||||
.tabs { width: 100%; }
|
||||
.tab { flex: 1; justify-content: center; padding: 8px 6px; font-size: 13px; }
|
||||
.tab .label-full { display: none; }
|
||||
.tab .label-short { display: inline; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user