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