Files
pragma-stack/backend/app/api/routes/users.py
Felipe Cardoso a3f78dc801 refactor(tests): replace crud references with repo across repository test files
- Updated import statements and test logic to align with `repositories` naming changes.
- Adjusted documentation and test names for consistency with the updated naming convention.
- Improved test descriptions to reflect the repository-based structure.
2026-03-01 19:22:16 +01:00

358 lines
10 KiB
Python
Executable File

"""
User management endpoints for database operations.
"""
import logging
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Request, status
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.dependencies.auth import get_current_superuser, get_current_user
from app.core.database import get_db
from app.core.exceptions import AuthorizationError, ErrorCode
from app.models.user import User
from app.schemas.common import (
MessageResponse,
PaginatedResponse,
PaginationParams,
SortParams,
create_pagination_meta,
)
from app.schemas.users import PasswordChange, UserResponse, UserUpdate
from app.services.auth_service import AuthenticationError, AuthService
from app.services.user_service import user_service
logger = logging.getLogger(__name__)
router = APIRouter()
limiter = Limiter(key_func=get_remote_address)
@router.get(
"",
response_model=PaginatedResponse[UserResponse],
summary="List Users",
description="""
List all users with pagination, filtering, and sorting (admin only).
**Authentication**: Required (Bearer token)
**Authorization**: Superuser only
**Filtering**: is_active, is_superuser
**Sorting**: Any user field (email, first_name, last_name, created_at, etc.)
**Rate Limit**: 60 requests/minute
""",
operation_id="list_users",
)
async def list_users(
pagination: PaginationParams = Depends(),
sort: SortParams = Depends(),
is_active: bool | None = Query(None, description="Filter by active status"),
is_superuser: bool | None = Query(None, description="Filter by superuser status"),
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
) -> Any:
"""
List all users with pagination, filtering, and sorting.
Only accessible by superusers.
"""
try:
# Build filters
filters = {}
if is_active is not None:
filters["is_active"] = is_active
if is_superuser is not None:
filters["is_superuser"] = is_superuser
# Get paginated users with total count
users, total = await user_service.list_users(
db,
skip=pagination.offset,
limit=pagination.limit,
sort_by=sort.sort_by,
sort_order=sort.sort_order.value if sort.sort_order else "asc",
filters=filters if filters else None,
)
# Create pagination metadata
pagination_meta = create_pagination_meta(
total=total,
page=pagination.page,
limit=pagination.limit,
items_count=len(users),
)
return PaginatedResponse(data=users, pagination=pagination_meta)
except Exception as e:
logger.exception("Error listing users: %s", e)
raise
@router.get(
"/me",
response_model=UserResponse,
summary="Get Current User",
description="""
Get the current authenticated user's profile.
**Authentication**: Required (Bearer token)
**Rate Limit**: 60 requests/minute
""",
operation_id="get_current_user_profile",
)
async def get_current_user_profile(
current_user: User = Depends(get_current_user),
) -> Any:
"""Get current user's profile."""
return current_user
@router.patch(
"/me",
response_model=UserResponse,
summary="Update Current User",
description="""
Update the current authenticated user's profile.
Users can update their own profile information (except is_superuser).
**Authentication**: Required (Bearer token)
**Rate Limit**: 30 requests/minute
""",
operation_id="update_current_user",
)
async def update_current_user(
user_update: UserUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Update current user's profile.
Users cannot elevate their own permissions (protected by UserUpdate schema validator).
"""
try:
updated_user = await user_service.update_user(
db, user=current_user, obj_in=user_update
)
logger.info("User %s updated their profile", current_user.id)
return updated_user
except ValueError as e:
logger.error("Error updating user %s: %s", current_user.id, e)
raise
except Exception as e:
logger.exception("Unexpected error updating user %s: %s", current_user.id, e)
raise
@router.get(
"/{user_id}",
response_model=UserResponse,
summary="Get User by ID",
description="""
Get a specific user by their ID.
**Authentication**: Required (Bearer token)
**Authorization**:
- Regular users: Can only access their own profile
- Superusers: Can access any profile
**Rate Limit**: 60 requests/minute
""",
operation_id="get_user_by_id",
)
async def get_user_by_id(
user_id: UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Get user by ID.
Users can only view their own profile unless they are superusers.
"""
# Check permissions
if str(user_id) != str(current_user.id) and not current_user.is_superuser:
logger.warning(
"User %s attempted to access user %s without permission",
current_user.id,
user_id,
)
raise AuthorizationError(
message="Not enough permissions to view this user",
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS,
)
# Get user
user = await user_service.get_user(db, str(user_id))
return user
@router.patch(
"/{user_id}",
response_model=UserResponse,
summary="Update User",
description="""
Update a specific user by their ID.
**Authentication**: Required (Bearer token)
**Authorization**:
- Regular users: Can only update their own profile (except is_superuser)
- Superusers: Can update any profile
**Rate Limit**: 30 requests/minute
""",
operation_id="update_user",
)
async def update_user(
user_id: UUID,
user_update: UserUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Update user by ID.
Users can update their own profile. Superusers can update any profile.
Superuser field modification is prevented by UserUpdate schema validator.
"""
# Check permissions
is_own_profile = str(user_id) == str(current_user.id)
if not is_own_profile and not current_user.is_superuser:
logger.warning(
"User %s attempted to update user %s without permission",
current_user.id,
user_id,
)
raise AuthorizationError(
message="Not enough permissions to update this user",
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS,
)
# Get user
user = await user_service.get_user(db, str(user_id))
try:
updated_user = await user_service.update_user(db, user=user, obj_in=user_update)
logger.info("User %s updated by %s", user_id, current_user.id)
return updated_user
except ValueError as e:
logger.error("Error updating user %s: %s", user_id, e)
raise
except Exception as e:
logger.exception("Unexpected error updating user %s: %s", user_id, e)
raise
@router.patch(
"/me/password",
response_model=MessageResponse,
summary="Change Current User Password",
description="""
Change the current authenticated user's password.
Requires the current password for verification.
**Authentication**: Required (Bearer token)
**Rate Limit**: 5 requests/minute
""",
operation_id="change_current_user_password",
)
@limiter.limit("5/minute")
async def change_current_user_password(
request: Request,
password_change: PasswordChange,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Change current user's password.
Requires current password for verification.
"""
try:
success = await AuthService.change_password(
db=db,
user_id=current_user.id,
current_password=password_change.current_password,
new_password=password_change.new_password,
)
if success:
logger.info("User %s changed their password", current_user.id)
return MessageResponse(
success=True, message="Password changed successfully"
)
except AuthenticationError as e:
logger.warning(
"Failed password change attempt for user %s: %s", current_user.id, e
)
raise AuthorizationError(
message=str(e), error_code=ErrorCode.INVALID_CREDENTIALS
)
except Exception as e:
logger.error("Error changing password for user %s: %s", current_user.id, e)
raise
@router.delete(
"/{user_id}",
status_code=status.HTTP_200_OK,
response_model=MessageResponse,
summary="Delete User",
description="""
Delete a specific user by their ID.
**Authentication**: Required (Bearer token)
**Authorization**: Superuser only
**Rate Limit**: 10 requests/minute
**Note**: This performs a hard delete. Consider implementing soft deletes for production.
""",
operation_id="delete_user",
)
async def delete_user(
user_id: UUID,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Delete user by ID (superuser only).
This is a hard delete operation.
"""
# Prevent self-deletion
if str(user_id) == str(current_user.id):
raise AuthorizationError(
message="Cannot delete your own account",
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS,
)
# Get user (raises NotFoundError if not found)
await user_service.get_user(db, str(user_id))
try:
# Use soft delete instead of hard delete
await user_service.soft_delete_user(db, str(user_id))
logger.info("User %s soft-deleted by %s", user_id, current_user.id)
return MessageResponse(
success=True, message=f"User {user_id} deleted successfully"
)
except ValueError as e:
logger.error("Error deleting user %s: %s", user_id, e)
raise
except Exception as e:
logger.exception("Unexpected error deleting user %s: %s", user_id, e)
raise