Files
vidconf/backend/api/users.py

146 lines
5.8 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.
"""Роутер профиля текущего пользователя, аватара и списка пользователей."""
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 repositories.users import UserRepository
from schemas.auth import PasswordChangeIn, ProfileUpdateIn, UserListItemOut, UserProfileOut
from services.avatars import AvatarInvalidTypeError, AvatarTooLargeError, avatar_url
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.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 verify_password(data.current_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_current_password"
)
user.password_hash = 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 _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,
)