231 lines
8.1 KiB
Python
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="Недействительный токен обновления"
|
|
)
|