Первоначальная версия VidConf
This commit is contained in:
145
backend/api/users.py
Normal file
145
backend/api/users.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Роутер профиля текущего пользователя, аватара и списка пользователей."""
|
||||
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user