new features

This commit is contained in:
Daniil
2026-02-27 23:33:56 +03:00
parent 937e58859a
commit dc04efe0fb
41 changed files with 2067 additions and 141 deletions
+60 -10
View File
@@ -2,17 +2,21 @@ 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,
@@ -28,6 +32,21 @@ 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()
@@ -49,10 +68,11 @@ def _issue_tokens(user: User) -> tuple[str, str]:
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 [UserRead.model_validate(u) for u in users]
return [await _resolve_avatar(u, storage) for u in users]
@users_router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED)
@@ -60,6 +80,7 @@ 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:
@@ -67,12 +88,29 @@ async def create_user_endpoint(
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
return UserRead.model_validate(user)
return await _resolve_avatar(user, storage)
@users_router.get("/me/", response_model=UserRead)
async def me(current_user: User = Depends(get_current_user)) -> UserRead:
return UserRead.model_validate(current_user)
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)
@@ -80,6 +118,7 @@ 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)
@@ -89,7 +128,7 @@ async def retrieve_user(
if not current_user.is_staff and user.id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
return UserRead.model_validate(user)
return await _resolve_avatar(user, storage)
@users_router.patch("/{user_id}/", response_model=UserRead)
@@ -98,6 +137,7 @@ async def patch_user(
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)
@@ -112,7 +152,7 @@ async def patch_user(
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
return UserRead.model_validate(user)
return await _resolve_avatar(user, storage)
@users_router.delete("/{user_id}/", status_code=status.HTTP_204_NO_CONTENT)
@@ -136,7 +176,11 @@ async def delete_user(
@auth_router.post(
"/register", response_model=UserRegisterResponse, status_code=status.HTTP_201_CREATED
)
async def register(body: UserRegister, db: AsyncSession = Depends(get_db)) -> UserRegisterResponse:
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)
@@ -144,18 +188,24 @@ async def register(body: UserRegister, db: AsyncSession = Depends(get_db)) -> Us
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
access, refresh = _issue_tokens(user)
return UserRegisterResponse(user=UserRead.model_validate(user), access=access, refresh=refresh)
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)) -> 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="Invalid credentials")
access, refresh = _issue_tokens(user)
return UserRegisterResponse(user=UserRead.model_validate(user), access=access, refresh=refresh)
user_read = await _resolve_avatar(user, storage)
return UserRegisterResponse(user=user_read, access=access, refresh=refresh)
@auth_router.post("/refresh", response_model=TokenRefreshResponse)