Files
fast-next-template/backend/app/api/routes/users.py
Felipe Cardoso 313e6691b5 Add async CRUD base, async database configuration, soft delete for users, and composite indexes
- Introduced `CRUDBaseAsync` for reusable async operations.
- Configured async database connection using SQLAlchemy 2.0 patterns with `asyncpg`.
- Added `deleted_at` column and soft delete functionality to the `User` model, including related Alembic migration.
- Optimized queries by adding composite indexes for common user filtering scenarios.
- Extended tests: added cases for token-based security utilities and user management endpoints.
2025-10-30 16:45:01 +01:00

395 lines
11 KiB
Python

"""
User management endpoints for CRUD operations.
"""
import logging
from typing import Any, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status, Request
from sqlalchemy.orm import Session
from slowapi import Limiter
from slowapi.util import get_remote_address
from app.api.dependencies.auth import get_current_user, get_current_superuser
from app.core.database import get_db
from app.crud.user import user as user_crud
from app.models.user import User
from app.schemas.users import UserResponse, UserUpdate, PasswordChange
from app.schemas.common import (
PaginationParams,
PaginatedResponse,
MessageResponse,
SortParams,
create_pagination_meta
)
from app.services.auth_service import AuthService, AuthenticationError
from app.core.exceptions import (
NotFoundError,
AuthorizationError,
ErrorCode
)
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"
)
def list_users(
pagination: PaginationParams = Depends(),
sort: SortParams = Depends(),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
is_superuser: Optional[bool] = Query(None, description="Filter by superuser status"),
current_user: User = Depends(get_current_superuser),
db: Session = 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 = user_crud.get_multi_with_total(
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.error(f"Error listing users: {str(e)}", exc_info=True)
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"
)
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"
)
def update_current_user(
user_update: UserUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
) -> Any:
"""
Update current user's profile.
Users cannot elevate their own permissions (is_superuser).
"""
# Prevent users from making themselves superuser
if getattr(user_update, 'is_superuser', None) is not None:
logger.warning(f"User {current_user.id} attempted to modify is_superuser field")
raise AuthorizationError(
message="Cannot modify superuser status",
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS
)
try:
updated_user = user_crud.update(
db,
db_obj=current_user,
obj_in=user_update
)
logger.info(f"User {current_user.id} updated their profile")
return updated_user
except ValueError as e:
logger.error(f"Error updating user {current_user.id}: {str(e)}")
raise
except Exception as e:
logger.error(f"Unexpected error updating user {current_user.id}: {str(e)}", exc_info=True)
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"
)
def get_user_by_id(
user_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = 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(
f"User {current_user.id} attempted to access user {user_id} without permission"
)
raise AuthorizationError(
message="Not enough permissions to view this user",
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS
)
# Get user
user = user_crud.get(db, id=str(user_id))
if not user:
raise NotFoundError(
message=f"User with id {user_id} not found",
error_code=ErrorCode.USER_NOT_FOUND
)
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"
)
def update_user(
user_id: UUID,
user_update: UserUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
) -> Any:
"""
Update user by ID.
Users can update their own profile. Superusers can update any profile.
Regular users cannot modify is_superuser field.
"""
# 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(
f"User {current_user.id} attempted to update user {user_id} without permission"
)
raise AuthorizationError(
message="Not enough permissions to update this user",
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS
)
# Get user
user = user_crud.get(db, id=str(user_id))
if not user:
raise NotFoundError(
message=f"User with id {user_id} not found",
error_code=ErrorCode.USER_NOT_FOUND
)
# Prevent non-superusers from modifying superuser status
if getattr(user_update, 'is_superuser', None) is not None and not current_user.is_superuser:
logger.warning(f"User {current_user.id} attempted to modify is_superuser field")
raise AuthorizationError(
message="Cannot modify superuser status",
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS
)
try:
updated_user = user_crud.update(db, db_obj=user, obj_in=user_update)
logger.info(f"User {user_id} updated by {current_user.id}")
return updated_user
except ValueError as e:
logger.error(f"Error updating user {user_id}: {str(e)}")
raise
except Exception as e:
logger.error(f"Unexpected error updating user {user_id}: {str(e)}", exc_info=True)
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")
def change_current_user_password(
request: Request,
password_change: PasswordChange,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
) -> Any:
"""
Change current user's password.
Requires current password for verification.
"""
try:
success = 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(f"User {current_user.id} changed their password")
return MessageResponse(
success=True,
message="Password changed successfully"
)
except AuthenticationError as e:
logger.warning(f"Failed password change attempt for user {current_user.id}: {str(e)}")
raise AuthorizationError(
message=str(e),
error_code=ErrorCode.INVALID_CREDENTIALS
)
except Exception as e:
logger.error(f"Error changing password for user {current_user.id}: {str(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"
)
def delete_user(
user_id: UUID,
current_user: User = Depends(get_current_superuser),
db: Session = 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
user = user_crud.get(db, id=str(user_id))
if not user:
raise NotFoundError(
message=f"User with id {user_id} not found",
error_code=ErrorCode.USER_NOT_FOUND
)
try:
# Use soft delete instead of hard delete
user_crud.soft_delete(db, id=str(user_id))
logger.info(f"User {user_id} soft-deleted by {current_user.id}")
return MessageResponse(
success=True,
message=f"User {user_id} deleted successfully"
)
except ValueError as e:
logger.error(f"Error deleting user {user_id}: {str(e)}")
raise
except Exception as e:
logger.error(f"Unexpected error deleting user {user_id}: {str(e)}", exc_info=True)
raise