Files
Daniil 259d3da89f rev 4
2026-04-07 13:42:45 +03:00

231 lines
8.1 KiB
Python

from __future__ import annotations
import uuid
from datetime import timedelta
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Response, status
from jwt import ExpiredSignatureError, InvalidTokenError
from sqlalchemy.ext.asyncio import AsyncSession
from cpv3.infrastructure.auth import get_current_user
from cpv3.infrastructure.deps import get_storage
from cpv3.infrastructure.security import create_token, decode_token
from cpv3.infrastructure.settings import get_settings
from cpv3.infrastructure.storage.base import StorageService
from cpv3.db.session import get_db
from cpv3.modules.users.models import User
from cpv3.modules.users.schemas import (
PasswordChange,
TokenRefresh,
TokenRefreshResponse,
UserCreate,
UserLogin,
UserRead,
UserRegister,
UserRegisterResponse,
UserUpdate,
)
from cpv3.modules.users.service import UserService
users_router = APIRouter(prefix="/api/users", tags=["Users"])
auth_router = APIRouter(prefix="/auth", tags=["auth"])
def _is_s3_key(value: str) -> bool:
"""Return True if value looks like a bare S3 key, not a full URL."""
parsed = urlparse(value)
return not parsed.scheme and not parsed.netloc
async def _resolve_avatar(user: User, storage: StorageService) -> UserRead:
"""Build UserRead with a fresh presigned avatar URL."""
data = UserRead.model_validate(user)
if data.avatar:
if _is_s3_key(data.avatar):
data.avatar = await storage.url(data.avatar)
return data
def _issue_tokens(user: User) -> tuple[str, str]:
settings = get_settings()
access = create_token(
subject=str(user.id),
token_type="access",
expires_in=timedelta(minutes=settings.jwt_access_ttl_minutes),
extra={"is_staff": user.is_staff, "is_superuser": user.is_superuser},
)
refresh = create_token(
subject=str(user.id),
token_type="refresh",
expires_in=timedelta(days=settings.jwt_refresh_ttl_days),
)
return access, refresh
@users_router.get("/", response_model=list[UserRead])
async def list_all_users(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
storage: StorageService = Depends(get_storage),
) -> list[UserRead]:
service = UserService(db)
users = await service.list_users(requester=current_user)
return [await _resolve_avatar(u, storage) for u in users]
@users_router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def create_user_endpoint(
body: UserCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
storage: StorageService = Depends(get_storage),
) -> UserRead:
service = UserService(db)
try:
user = await service.create_user(body, requester=current_user)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
return await _resolve_avatar(user, storage)
@users_router.get("/me/", response_model=UserRead)
async def me(
current_user: User = Depends(get_current_user),
storage: StorageService = Depends(get_storage),
) -> UserRead:
return await _resolve_avatar(current_user, storage)
@users_router.post("/me/change-password/", status_code=status.HTTP_204_NO_CONTENT)
async def change_password(
body: PasswordChange,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Response:
service = UserService(db)
try:
await service.change_password(current_user, body.current_password, body.new_password)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
return Response(status_code=status.HTTP_204_NO_CONTENT)
@users_router.get("/{user_id}/", response_model=UserRead)
async def retrieve_user(
user_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
storage: StorageService = Depends(get_storage),
) -> UserRead:
service = UserService(db)
user = await service.get_user_by_id(user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and user.id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
return await _resolve_avatar(user, storage)
@users_router.patch("/{user_id}/", response_model=UserRead)
async def patch_user(
user_id: uuid.UUID,
body: UserUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
storage: StorageService = Depends(get_storage),
) -> UserRead:
service = UserService(db)
user = await service.get_user_by_id(user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and user.id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
try:
user = await service.update_user(user, body)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
return await _resolve_avatar(user, storage)
@users_router.delete("/{user_id}/", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Response:
service = UserService(db)
user = await service.get_user_by_id(user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and user.id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
await service.deactivate_user(user)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@auth_router.post(
"/register", response_model=UserRegisterResponse, status_code=status.HTTP_201_CREATED
)
async def register(
body: UserRegister,
db: AsyncSession = Depends(get_db),
storage: StorageService = Depends(get_storage),
) -> UserRegisterResponse:
service = UserService(db)
try:
user = await service.register_user(body)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
access, refresh = _issue_tokens(user)
user_read = await _resolve_avatar(user, storage)
return UserRegisterResponse(user=user_read, access=access, refresh=refresh)
@auth_router.post("/login", response_model=UserRegisterResponse)
async def login(
body: UserLogin,
db: AsyncSession = Depends(get_db),
storage: StorageService = Depends(get_storage),
) -> UserRegisterResponse:
service = UserService(db)
user = await service.authenticate(body.username, body.password)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверные учётные данные")
access, refresh = _issue_tokens(user)
user_read = await _resolve_avatar(user, storage)
return UserRegisterResponse(user=user_read, access=access, refresh=refresh)
@auth_router.post("/refresh", response_model=TokenRefreshResponse)
async def refresh(body: TokenRefresh) -> TokenRefreshResponse:
try:
payload = decode_token(body.refresh)
if payload.get("type") != "refresh":
raise InvalidTokenError("wrong type")
user_id = uuid.UUID(str(payload.get("sub")))
settings = get_settings()
access = create_token(
subject=str(user_id),
token_type="access",
expires_in=timedelta(minutes=settings.jwt_access_ttl_minutes),
)
return TokenRefreshResponse(access=access, refresh=body.refresh)
except (ExpiredSignatureError, InvalidTokenError, ValueError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Недействительный токен обновления"
)