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