146 lines
5.8 KiB
Python
146 lines
5.8 KiB
Python
"""Роутер профиля текущего пользователя, аватара и списка пользователей."""
|
||
|
||
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,
|
||
)
|