Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

145
backend/api/users.py Normal file
View 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,
)