Files
vidconf/backend/schemas/auth.py
Max Ronzhin fec9255baa 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-БД модуль ронял чужие тесты, которые считают себя изолированными.
2026-08-10 08:59:16 +03:00

150 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Pydantic-схемы для аутентификации и профиля пользователя."""
import uuid
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class RegisterIn(BaseModel):
"""Тело запроса регистрации нового пользователя.
`team_id` допустим только при включённой настройке инстанса
`registration_team_choice` (см. `GET /auth/registration-options`) и
существующей команде — иначе `POST /auth/register` вернёт 400.
`consent_accepted` обязан быть `True`, если в настройках инстанса
включено `consent_required` (согласие на обработку персональных
данных) — иначе `POST /auth/register` вернёт 400. Игнорируется, если
настройка выключена (второй эшелон проверки — фронт тоже блокирует
кнопку, но сервер не полагается на это).
"""
email: EmailStr
name_user: str = Field(min_length=1, max_length=255)
password: str = Field(min_length=8)
team_id: uuid.UUID | None = None
consent_accepted: bool = False
class VerifyEmailIn(BaseModel):
"""Тело запроса подтверждения email."""
token: str = Field(min_length=1)
class TokenOut(BaseModel):
"""Ответ с access-токеном; refresh-токен передаётся отдельно в httpOnly cookie."""
access_token: str
token_type: str = "bearer"
class UserOut(BaseModel):
"""Публичное представление пользователя (профиль текущего пользователя).
`email: str`, а не `EmailStr` — намеренно: это response-схема,
отражающая уже сохранённые в БД данные, а не принимающая новый ввод.
`EmailStr` дополнительно отсекает синтаксически валидные, но
зарезервированные домены (`.local`, `.test` и т.п. из RFC 6761) — валидный
email на входе (`RegisterIn`, ниже) мог быть заведён напрямую в БД (seed,
ручная миграция) с таким доменом; строгая `EmailStr` на выходе привела бы
к 500 `ResponseValidationError` для уже существующих пользователей.
"""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
name_user: str
role: str
class UserListItemOut(BaseModel):
"""Элемент списка пользователей (пикер участников конференции/мультиселект брони)."""
id: uuid.UUID
display_name: str
avatar_url: str | None = None
class ProfileUpdateIn(BaseModel):
"""Тело правки профиля текущего пользователя.
Email НЕ принимается — read-only поле профиля. `team_id` не различает
«поле не передано» и «явный `null`» через сравнение с `None` — роутер
читает `model_fields_set` (тот же приём, что `AdminUserUpdateIn.team_id`).
"""
name_user: str | None = Field(default=None, min_length=1, max_length=255)
team_id: uuid.UUID | None = None
class UserProfileOut(UserOut):
"""Профиль пользователя (свой либо открытый администратором) — `UserOut` + аватар/команда."""
avatar_url: str | None = None
team_id: uuid.UUID | None = None
team_name: str | None = None
class UserBackgroundOut(BaseModel):
"""Своя картинка пользователя для замены фона видео (`GET /users/me/backgrounds`).
Отдаётся только URL файла (`/media/backgrounds/...`, раздаёт nginx) и id для
удаления — путь на диске наружу не показывается.
"""
id: uuid.UUID
url: str
class UserBackgroundsOut(BaseModel):
"""Список своих картинок фона вместе с лимитом.
Лимит приезжает с сервера, а не зашит в интерфейс: он проверяется на
сервере (`services/backgrounds.py`), и фронт не должен угадывать его
отдельной константой, которая разъедется при первой же правке.
"""
items: list[UserBackgroundOut]
limit: int
class PasswordChangeIn(BaseModel):
"""Тело смены пароля текущим пользователем (`POST /users/me/password`).
Политика сложности `new_password` — та же, что при регистрации
(`RegisterIn.password`, min 8 символов).
"""
current_password: str
new_password: str = Field(min_length=8)
class RegistrationTeamOptionOut(BaseModel):
"""Команда в списке опций публичной карточки регистрации."""
id: uuid.UUID
name: str
class RegistrationOptionsOut(BaseModel):
"""Публичные опции формы регистрации (`GET /auth/registration-options`).
`teams` отдаётся только при `team_choice_enabled=True` — иначе пустой
список (справочник команд не раскрывается, пока выбор выключен).
`email_domains` — эталонные домены при включённой верификации регистрации
по домену email (настройка инстанса `registration_email_domain`), иначе
пустой список.
"""
team_choice_enabled: bool
teams: list[RegistrationTeamOptionOut]
email_domains: list[str] = Field(default_factory=list)
# Согласие на обработку персональных данных: `consent_required` — обязательна
# ли галочка на форме регистрации; `consent_text`/`consent_version` отдаются
# ВСЕГДА, независимо от `consent_required` — той же строкой пользуется
# публичная страница регламента, доступная и при выключенном модуле.
consent_required: bool = False
consent_text: str = ""
consent_version: int = 1