Отключаемый в админке модуль `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-БД модуль ронял чужие тесты, которые считают себя изолированными.
150 lines
6.6 KiB
Python
150 lines
6.6 KiB
Python
"""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
|