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
+4
View File
@@ -71,6 +71,10 @@ class UserRepository:
await self._session.refresh(user)
return user
async def update_password(self, user: User, new_hash: str) -> None:
user.password_hash = new_hash
await self._session.commit()
async def deactivate(self, user: User) -> None:
user.is_active = False
await self._session.commit()
+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)
+5
View File
@@ -66,6 +66,11 @@ class UserRegisterResponse(Schema):
refresh: str
class PasswordChange(Schema):
current_password: str
new_password: str
class TokenRefresh(Schema):
refresh: str
+14 -1
View File
@@ -4,7 +4,7 @@ import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from cpv3.infrastructure.security import verify_password
from cpv3.infrastructure.security import hash_password, verify_password
from cpv3.modules.users.models import User
from cpv3.modules.users.repository import UserRepository
from cpv3.modules.users.schemas import UserCreate, UserRegister, UserUpdate
@@ -40,6 +40,12 @@ class UserService:
async def deactivate_user(self, user: User) -> None:
await self._repo.deactivate(user)
async def change_password(self, user: User, current_password: str, new_password: str) -> None:
if not verify_password(current_password, user.password_hash):
raise ValueError("Current password is incorrect")
new_hash = hash_password(new_password)
await self._repo.update_password(user, new_hash)
async def authenticate(self, username: str, password: str) -> User | None:
user = await self._repo.get_by_username(username)
if user is None:
@@ -87,6 +93,13 @@ async def deactivate_user(session: AsyncSession, user: User) -> None:
await service.deactivate_user(user)
async def change_password(
session: AsyncSession, user: User, current_password: str, new_password: str
) -> None:
service = UserService(session)
await service.change_password(user, current_password, new_password)
async def authenticate(session: AsyncSession, username: str, password: str) -> User | None:
service = UserService(session)
return await service.authenticate(username, password)