feat(backend): модуль «замена фона» и хранилище своих картинок

Отключаемый в админке модуль `virtual_background` (дефолт — выключен, чтобы
обновление не меняло продукт у тех, кто ничего не просил). Флаг едет клиенту
двумя путями: на публичные страницы входа — через `GET /public/settings`,
участнику комнаты — в join-ответе (`JoinOut`), потому что значение нужно на
руках ДО первого рендера комнаты, а `/admin/settings` доступен только админу.

Свои картинки пользователя (`/users/me/backgrounds`, GET/POST/DELETE):
файлы на диске (`backgrounds/{user_id}/{id}.{ext}`), в БД только путь — как у
аватаров, «чтобы не грузили БД». Лимит в 10 штук проверяется на сервере под
блокировкой строки пользователя: две одновременные загрузки иначе обе увидели
бы «уже девять» и обе прошли бы. Удаление сносит и запись, и файл; чужую
картинку по её id удалить нельзя — владелец в условии запроса.

Валидация загрузки (допустимые форматы, магические байты, реальный размер)
выделена из `services/avatars.py` в общий `services/images.py`: правила у
аватара и фона одни и те же, а разъехавшись, они дали бы дыру ровно там, ради
чего проверка и написана. Публичный API аватаров не изменился.

Сжимает картинку клиент (Pillow на бэкенде нет), но серверная валидация
остаётся полноценной — запрос может прийти и мимо интерфейса.

Новый ключ настройки вписан в `_MANAGED_KEYS` тестов: без этого включённый
в общей dev-БД модуль ронял чужие тесты, которые считают себя изолированными.
This commit is contained in:
2026-08-10 08:59:16 +03:00
parent 88401d6aa1
commit fec9255baa
21 changed files with 717 additions and 64 deletions

View File

@@ -56,6 +56,7 @@ _KEY_CONTACT_EMAIL = "contact_email"
_KEY_MEDIA_LIMITS = "media_limits"
_KEY_CONSENT_POLICY = "consent_policy"
_KEY_DEVICE_CHECK = "device_check"
_KEY_VIRTUAL_BACKGROUND = "virtual_background"
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
_KEY_CHAT,
@@ -81,6 +82,10 @@ _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "d
_DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None}
_DEFAULT_MEDIA_LIMITS_VALUE: dict[str, Any] = {"publish_quality_cap": "off", "stage_max_tiles": 25}
_DEFAULT_DEVICE_CHECK_VALUE = {"enabled": False}
_DEFAULT_VIRTUAL_BACKGROUND_VALUE = {"enabled": False}
"""Замена фона видео (сессия 35). Дефолт — выключено: фича постоянно считает
нейросеть сегментации на клиенте, и включать её самим фактом обновления у тех,
кто ничего не просил, нельзя (то же правило, что и у остальных модулей)."""
DEFAULT_CONSENT_POLICY_TEXT = """Это типовой шаблон для предварительной демонстрации. Текст не проходил проверку юриста и не может использоваться как окончательная редакция без такой проверки. Администратор обязан заменить плейсхолдеры в квадратных скобках и, при необходимости, весь текст — под свою организацию и юрисдикцию.
@@ -154,6 +159,7 @@ class SettingsUpdateIn(BaseModel):
consent_required: bool | None = None
consent_policy_text: str | None = None
device_check_enabled: bool | None = None
virtual_background_enabled: bool | None = None
class BootstrapOverrides(BaseModel):
@@ -207,6 +213,7 @@ def build_bootstrap_defaults(
_KEY_MEDIA_LIMITS: dict(_DEFAULT_MEDIA_LIMITS_VALUE),
_KEY_CONSENT_POLICY: dict(_DEFAULT_CONSENT_POLICY_VALUE),
_KEY_DEVICE_CHECK: dict(_DEFAULT_DEVICE_CHECK_VALUE),
_KEY_VIRTUAL_BACKGROUND: dict(_DEFAULT_VIRTUAL_BACKGROUND_VALUE),
}
if overrides is None:
return defaults
@@ -345,6 +352,10 @@ class InstanceSettingsService:
cfg.device_check_enabled = patch.device_check_enabled
await self._set(_KEY_DEVICE_CHECK, {"enabled": patch.device_check_enabled})
if patch.virtual_background_enabled is not None:
cfg.virtual_background_enabled = patch.virtual_background_enabled
await self._set(_KEY_VIRTUAL_BACKGROUND, {"enabled": patch.virtual_background_enabled})
if patch.registration_team_choice is not None:
cfg.registration_team_choice = patch.registration_team_choice
await self._set(
@@ -616,4 +627,7 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
device_check_enabled=rows.get(_KEY_DEVICE_CHECK, _DEFAULT_DEVICE_CHECK_VALUE).get(
"enabled", False
),
virtual_background_enabled=rows.get(
_KEY_VIRTUAL_BACKGROUND, _DEFAULT_VIRTUAL_BACKGROUND_VALUE
).get("enabled", False),
)