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:
@@ -1,5 +1,6 @@
|
||||
"""Роутер профиля текущего пользователя, аватара и списка пользователей."""
|
||||
"""Роутер профиля текущего пользователя, аватара, картинок фона и списка пользователей."""
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
@@ -11,9 +12,27 @@ 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, UserListItemOut, UserProfileOut
|
||||
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,
|
||||
@@ -89,6 +108,60 @@ async def delete_current_user_avatar(
|
||||
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,
|
||||
@@ -126,6 +199,14 @@ async def list_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)
|
||||
|
||||
Reference in New Issue
Block a user