Files
vidconf/backend/api/users.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

227 lines
9.1 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.
"""Роутер профиля текущего пользователя, аватара, картинок фона и списка пользователей."""
import uuid
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import get_current_user
from core.config import get_settings
from core.db import get_session
from core.security import hash_password, verify_password
from models.user import User
from models.user_background import UserBackground
from repositories.users import UserRepository
from schemas.auth import (
PasswordChangeIn,
ProfileUpdateIn,
UserBackgroundOut,
UserBackgroundsOut,
UserListItemOut,
UserProfileOut,
)
from services.avatars import AvatarInvalidTypeError, AvatarTooLargeError, avatar_url
from services.backgrounds import (
MAX_BACKGROUNDS_PER_USER,
BackgroundInvalidTypeError,
BackgroundLimitReachedError,
BackgroundTooLargeError,
add_background,
background_url,
delete_background,
list_backgrounds,
)
from services.profile import (
TeamNotFoundError,
clear_avatar,
resolve_team_name,
update_profile_fields,
)
from services.profile import set_avatar as _set_avatar
router = APIRouter(prefix="/api/v1/users", tags=["users"])
# Число совпадений, возвращаемых поиском по `q` (пикер участников).
SEARCH_LIMIT = 20
@router.get("/me", response_model=UserProfileOut)
async def read_current_user(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> UserProfileOut:
"""Вернуть профиль текущего аутентифицированного пользователя."""
return await _to_profile_out(session, user)
@router.patch("/me", response_model=UserProfileOut)
async def update_current_user(
data: ProfileUpdateIn,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> UserProfileOut:
"""Изменить ФИО и/или команду текущего пользователя; email — read-only."""
try:
await update_profile_fields(
session,
user,
name_user=data.name_user,
team_id=data.team_id,
team_id_is_set="team_id" in data.model_fields_set,
)
except TeamNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="team_not_found") from exc
await session.commit()
return await _to_profile_out(session, user)
@router.post("/me/avatar", response_model=UserProfileOut)
async def upload_current_user_avatar(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
file: Annotated[UploadFile, File()],
) -> UserProfileOut:
"""Загрузить аватар текущего пользователя (jpeg/png/webp, до 2 МБ)."""
try:
await _set_avatar(_media_root(), user, file)
except AvatarTooLargeError as exc:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="avatar_too_large"
) from exc
except AvatarInvalidTypeError as exc:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="avatar_invalid_type"
) from exc
await session.commit()
return await _to_profile_out(session, user)
@router.delete("/me/avatar", status_code=status.HTTP_204_NO_CONTENT)
async def delete_current_user_avatar(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> None:
"""Удалить аватар текущего пользователя."""
clear_avatar(_media_root(), user)
await session.commit()
@router.get("/me/backgrounds", response_model=UserBackgroundsOut)
async def list_current_user_backgrounds(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> UserBackgroundsOut:
"""Свои картинки для замены фона видео + лимит на их число."""
items = await list_backgrounds(session, user.id)
return _to_backgrounds_out(items)
@router.post(
"/me/backgrounds", response_model=UserBackgroundsOut, status_code=status.HTTP_201_CREATED
)
async def upload_current_user_background(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
file: Annotated[UploadFile, File()],
) -> UserBackgroundsOut:
"""Загрузить свою картинку фона (jpeg/png/webp, до 2 МБ, не более 10 штук).
Возвращает весь список заново, а не одну добавленную запись: интерфейсу всё
равно нужен свежий список с актуальным остатком лимита, и лишний GET следом
за POST не нужен.
"""
try:
await add_background(session, _media_root(), user.id, file)
except BackgroundLimitReachedError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="background_limit_reached"
) from exc
except BackgroundTooLargeError as exc:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="background_too_large"
) from exc
except BackgroundInvalidTypeError as exc:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="background_invalid_type"
) from exc
await session.commit()
return _to_backgrounds_out(await list_backgrounds(session, user.id))
@router.delete("/me/backgrounds/{background_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_current_user_background(
background_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> None:
"""Удалить свою картинку фона вместе с файлом на диске; чужую — 404."""
if not await delete_background(session, _media_root(), user.id, background_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="background_not_found")
await session.commit()
@router.post("/me/password", status_code=status.HTTP_204_NO_CONTENT)
async def change_current_user_password(
data: PasswordChangeIn,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> None:
"""Сменить пароль текущего пользователя.
Refresh-сессии сознательно НЕ отзываются — отзыв всех сессий появится
вместе со сбросом пароля по email (v0.1.0, см. ADR-005
`docs/architecture/adr/005-password-reset-deferred.md`).
"""
if not await verify_password(data.current_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_current_password"
)
user.password_hash = await hash_password(data.new_password)
await session.commit()
@router.get("", response_model=list[UserListItemOut])
async def list_users(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
q: Annotated[str | None, Query()] = None,
) -> list[UserListItemOut]:
"""Пикер участников конференции: без `q` — полный список; с `q` — поиск имя/email."""
media_root = _media_root()
users = await UserRepository(session).search(q=q, limit=SEARCH_LIMIT)
return [
UserListItemOut(
id=u.id, display_name=u.name_user, avatar_url=avatar_url(media_root, u.avatar_path)
)
for u in users
]
def _to_backgrounds_out(items: list[UserBackground]) -> UserBackgroundsOut:
"""Собрать ответ списка картинок фона: id + публичный URL, плюс лимит с сервера."""
return UserBackgroundsOut(
items=[UserBackgroundOut(id=item.id, url=background_url(item.path)) for item in items],
limit=MAX_BACKGROUNDS_PER_USER,
)
def _media_root() -> Path:
"""Каталог загруженных медиа-файлов (см. `core/config.py::Settings.media_root`)."""
return Path(get_settings().media_root)
async def _to_profile_out(session: AsyncSession, user: User) -> UserProfileOut:
"""Собрать `UserProfileOut` — общая сборка для своего профиля и карточки в админке."""
team_name = await resolve_team_name(session, user.team_id)
return UserProfileOut(
id=user.id,
email=user.email,
name_user=user.name_user,
role=user.role,
avatar_url=avatar_url(_media_root(), user.avatar_path),
team_id=user.team_id,
team_name=team_name,
)