# app/api/routes/admin.py """ Admin-specific endpoints for managing users and organizations. These endpoints require superuser privileges and provide CMS-like functionality for managing the application. """ import logging from typing import Any, List, Optional from uuid import UUID from enum import Enum from fastapi import APIRouter, Depends, Query, Body, status from sqlalchemy.orm import Session from pydantic import BaseModel, Field from app.api.dependencies.permissions import require_superuser from app.core.database import get_db from app.crud.user import user as user_crud from app.crud.organization import organization as organization_crud from app.models.user import User from app.models.user_organization import OrganizationRole from app.schemas.users import UserResponse, UserCreate, UserUpdate from app.schemas.organizations import ( OrganizationResponse, OrganizationCreate, OrganizationUpdate, OrganizationMemberResponse ) from app.schemas.common import ( PaginationParams, PaginatedResponse, MessageResponse, SortParams, create_pagination_meta ) from app.core.exceptions import NotFoundError, ErrorCode logger = logging.getLogger(__name__) router = APIRouter() # Schemas for bulk operations class BulkAction(str, Enum): """Supported bulk actions.""" ACTIVATE = "activate" DEACTIVATE = "deactivate" DELETE = "delete" class BulkUserAction(BaseModel): """Schema for bulk user actions.""" action: BulkAction = Field(..., description="Action to perform on selected users") user_ids: List[UUID] = Field(..., min_items=1, max_items=100, description="List of user IDs (max 100)") class BulkActionResult(BaseModel): """Result of a bulk action.""" success: bool affected_count: int failed_count: int message: str failed_ids: Optional[List[UUID]] = [] # ===== User Management Endpoints ===== @router.get( "/users", response_model=PaginatedResponse[UserResponse], summary="Admin: List All Users", description="Get paginated list of all users with filtering and search (admin only)", operation_id="admin_list_users" ) def admin_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"), search: Optional[str] = Query(None, description="Search by email, name"), admin: User = Depends(require_superuser), db: Session = Depends(get_db) ) -> Any: """ List all users with comprehensive filtering and search. Requires superuser privileges. """ 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 users with search users, total = user_crud.get_multi_with_total( db, skip=pagination.offset, limit=pagination.limit, sort_by=sort.sort_by or "created_at", sort_order=sort.sort_order.value if sort.sort_order else "desc", filters=filters if filters else None, search=search ) 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 (admin): {str(e)}", exc_info=True) raise @router.post( "/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED, summary="Admin: Create User", description="Create a new user (admin only)", operation_id="admin_create_user" ) def admin_create_user( user_in: UserCreate, admin: User = Depends(require_superuser), db: Session = Depends(get_db) ) -> Any: """ Create a new user with admin privileges. Allows setting is_superuser and other fields. """ try: user = user_crud.create(db, obj_in=user_in) logger.info(f"Admin {admin.email} created user {user.email}") return user except ValueError as e: logger.warning(f"Failed to create user: {str(e)}") raise NotFoundError( detail=str(e), error_code=ErrorCode.USER_ALREADY_EXISTS ) except Exception as e: logger.error(f"Error creating user (admin): {str(e)}", exc_info=True) raise @router.get( "/users/{user_id}", response_model=UserResponse, summary="Admin: Get User Details", description="Get detailed user information (admin only)", operation_id="admin_get_user" ) def admin_get_user( user_id: UUID, admin: User = Depends(require_superuser), db: Session = Depends(get_db) ) -> Any: """Get detailed information about a specific user.""" user = user_crud.get(db, id=user_id) if not user: raise NotFoundError( detail=f"User {user_id} not found", error_code=ErrorCode.USER_NOT_FOUND ) return user @router.put( "/users/{user_id}", response_model=UserResponse, summary="Admin: Update User", description="Update user information (admin only)", operation_id="admin_update_user" ) def admin_update_user( user_id: UUID, user_in: UserUpdate, admin: User = Depends(require_superuser), db: Session = Depends(get_db) ) -> Any: """Update user information with admin privileges.""" try: user = user_crud.get(db, id=user_id) if not user: raise NotFoundError( detail=f"User {user_id} not found", error_code=ErrorCode.USER_NOT_FOUND ) updated_user = user_crud.update(db, db_obj=user, obj_in=user_in) logger.info(f"Admin {admin.email} updated user {updated_user.email}") return updated_user except NotFoundError: raise except Exception as e: logger.error(f"Error updating user (admin): {str(e)}", exc_info=True) raise @router.delete( "/users/{user_id}", response_model=MessageResponse, summary="Admin: Delete User", description="Soft delete a user (admin only)", operation_id="admin_delete_user" ) def admin_delete_user( user_id: UUID, admin: User = Depends(require_superuser), db: Session = Depends(get_db) ) -> Any: """Soft delete a user (sets deleted_at timestamp).""" try: user = user_crud.get(db, id=user_id) if not user: raise NotFoundError( detail=f"User {user_id} not found", error_code=ErrorCode.USER_NOT_FOUND ) # Prevent deleting yourself if user.id == admin.id: raise NotFoundError( detail="Cannot delete your own account", error_code=ErrorCode.OPERATION_FORBIDDEN ) user_crud.soft_delete(db, id=user_id) logger.info(f"Admin {admin.email} deleted user {user.email}") return MessageResponse( success=True, message=f"User {user.email} has been deleted" ) except NotFoundError: raise except Exception as e: logger.error(f"Error deleting user (admin): {str(e)}", exc_info=True) raise @router.post( "/users/{user_id}/activate", response_model=MessageResponse, summary="Admin: Activate User", description="Activate a user account (admin only)", operation_id="admin_activate_user" ) def admin_activate_user( user_id: UUID, admin: User = Depends(require_superuser), db: Session = Depends(get_db) ) -> Any: """Activate a user account.""" try: user = user_crud.get(db, id=user_id) if not user: raise NotFoundError( detail=f"User {user_id} not found", error_code=ErrorCode.USER_NOT_FOUND ) user_crud.update(db, db_obj=user, obj_in={"is_active": True}) logger.info(f"Admin {admin.email} activated user {user.email}") return MessageResponse( success=True, message=f"User {user.email} has been activated" ) except NotFoundError: raise except Exception as e: logger.error(f"Error activating user (admin): {str(e)}", exc_info=True) raise @router.post( "/users/{user_id}/deactivate", response_model=MessageResponse, summary="Admin: Deactivate User", description="Deactivate a user account (admin only)", operation_id="admin_deactivate_user" ) def admin_deactivate_user( user_id: UUID, admin: User = Depends(require_superuser), db: Session = Depends(get_db) ) -> Any: """Deactivate a user account.""" try: user = user_crud.get(db, id=user_id) if not user: raise NotFoundError( detail=f"User {user_id} not found", error_code=ErrorCode.USER_NOT_FOUND ) # Prevent deactivating yourself if user.id == admin.id: raise NotFoundError( detail="Cannot deactivate your own account", error_code=ErrorCode.OPERATION_FORBIDDEN ) user_crud.update(db, db_obj=user, obj_in={"is_active": False}) logger.info(f"Admin {admin.email} deactivated user {user.email}") return MessageResponse( success=True, message=f"User {user.email} has been deactivated" ) except NotFoundError: raise except Exception as e: logger.error(f"Error deactivating user (admin): {str(e)}", exc_info=True) raise @router.post( "/users/bulk-action", response_model=BulkActionResult, summary="Admin: Bulk User Action", description="Perform bulk actions on multiple users (admin only)", operation_id="admin_bulk_user_action" ) def admin_bulk_user_action( bulk_action: BulkUserAction, admin: User = Depends(require_superuser), db: Session = Depends(get_db) ) -> Any: """ Perform bulk actions on multiple users. Supported actions: activate, deactivate, delete """ affected_count = 0 failed_count = 0 failed_ids = [] try: for user_id in bulk_action.user_ids: try: user = user_crud.get(db, id=user_id) if not user: failed_count += 1 failed_ids.append(user_id) continue # Prevent affecting yourself if user.id == admin.id: failed_count += 1 failed_ids.append(user_id) continue if bulk_action.action == BulkAction.ACTIVATE: user_crud.update(db, db_obj=user, obj_in={"is_active": True}) elif bulk_action.action == BulkAction.DEACTIVATE: user_crud.update(db, db_obj=user, obj_in={"is_active": False}) elif bulk_action.action == BulkAction.DELETE: user_crud.soft_delete(db, id=user_id) affected_count += 1 except Exception as e: logger.error(f"Error processing user {user_id} in bulk action: {str(e)}") failed_count += 1 failed_ids.append(user_id) logger.info( f"Admin {admin.email} performed bulk {bulk_action.action.value} " f"on {affected_count} users ({failed_count} failed)" ) return BulkActionResult( success=failed_count == 0, affected_count=affected_count, failed_count=failed_count, message=f"Bulk {bulk_action.action.value}: {affected_count} users affected, {failed_count} failed", failed_ids=failed_ids if failed_ids else None ) except Exception as e: logger.error(f"Error in bulk user action: {str(e)}", exc_info=True) raise # ===== Organization Management Endpoints ===== @router.get( "/organizations", response_model=PaginatedResponse[OrganizationResponse], summary="Admin: List Organizations", description="Get paginated list of all organizations (admin only)", operation_id="admin_list_organizations" ) def admin_list_organizations( pagination: PaginationParams = Depends(), is_active: Optional[bool] = Query(None, description="Filter by active status"), search: Optional[str] = Query(None, description="Search by name, slug, description"), admin: User = Depends(require_superuser), db: Session = Depends(get_db) ) -> Any: """List all organizations with filtering and search.""" try: orgs, total = organization_crud.get_multi_with_filters( db, skip=pagination.offset, limit=pagination.limit, is_active=is_active, search=search, sort_by="created_at", sort_order="desc" ) # Add member count to each organization orgs_with_count = [] for org in orgs: org_dict = { "id": org.id, "name": org.name, "slug": org.slug, "description": org.description, "is_active": org.is_active, "settings": org.settings, "created_at": org.created_at, "updated_at": org.updated_at, "member_count": organization_crud.get_member_count(db, organization_id=org.id) } orgs_with_count.append(OrganizationResponse(**org_dict)) pagination_meta = create_pagination_meta( total=total, page=pagination.page, limit=pagination.limit, items_count=len(orgs_with_count) ) return PaginatedResponse(data=orgs_with_count, pagination=pagination_meta) except Exception as e: logger.error(f"Error listing organizations (admin): {str(e)}", exc_info=True) raise @router.post( "/organizations", response_model=OrganizationResponse, status_code=status.HTTP_201_CREATED, summary="Admin: Create Organization", description="Create a new organization (admin only)", operation_id="admin_create_organization" ) def admin_create_organization( org_in: OrganizationCreate, admin: User = Depends(require_superuser), db: Session = Depends(get_db) ) -> Any: """Create a new organization.""" try: org = organization_crud.create(db, obj_in=org_in) logger.info(f"Admin {admin.email} created organization {org.name}") # Add member count org_dict = { "id": org.id, "name": org.name, "slug": org.slug, "description": org.description, "is_active": org.is_active, "settings": org.settings, "created_at": org.created_at, "updated_at": org.updated_at, "member_count": 0 } return OrganizationResponse(**org_dict) except ValueError as e: logger.warning(f"Failed to create organization: {str(e)}") raise NotFoundError( detail=str(e), error_code=ErrorCode.ALREADY_EXISTS ) except Exception as e: logger.error(f"Error creating organization (admin): {str(e)}", exc_info=True) raise @router.get( "/organizations/{org_id}", response_model=OrganizationResponse, summary="Admin: Get Organization Details", description="Get detailed organization information (admin only)", operation_id="admin_get_organization" ) def admin_get_organization( org_id: UUID, admin: User = Depends(require_superuser), db: Session = Depends(get_db) ) -> Any: """Get detailed information about a specific organization.""" org = organization_crud.get(db, id=org_id) if not org: raise NotFoundError( detail=f"Organization {org_id} not found", error_code=ErrorCode.NOT_FOUND ) org_dict = { "id": org.id, "name": org.name, "slug": org.slug, "description": org.description, "is_active": org.is_active, "settings": org.settings, "created_at": org.created_at, "updated_at": org.updated_at, "member_count": organization_crud.get_member_count(db, organization_id=org.id) } return OrganizationResponse(**org_dict) @router.put( "/organizations/{org_id}", response_model=OrganizationResponse, summary="Admin: Update Organization", description="Update organization information (admin only)", operation_id="admin_update_organization" ) def admin_update_organization( org_id: UUID, org_in: OrganizationUpdate, admin: User = Depends(require_superuser), db: Session = Depends(get_db) ) -> Any: """Update organization information.""" try: org = organization_crud.get(db, id=org_id) if not org: raise NotFoundError( detail=f"Organization {org_id} not found", error_code=ErrorCode.NOT_FOUND ) updated_org = organization_crud.update(db, db_obj=org, obj_in=org_in) logger.info(f"Admin {admin.email} updated organization {updated_org.name}") org_dict = { "id": updated_org.id, "name": updated_org.name, "slug": updated_org.slug, "description": updated_org.description, "is_active": updated_org.is_active, "settings": updated_org.settings, "created_at": updated_org.created_at, "updated_at": updated_org.updated_at, "member_count": organization_crud.get_member_count(db, organization_id=updated_org.id) } return OrganizationResponse(**org_dict) except NotFoundError: raise except Exception as e: logger.error(f"Error updating organization (admin): {str(e)}", exc_info=True) raise @router.delete( "/organizations/{org_id}", response_model=MessageResponse, summary="Admin: Delete Organization", description="Delete an organization (admin only)", operation_id="admin_delete_organization" ) def admin_delete_organization( org_id: UUID, admin: User = Depends(require_superuser), db: Session = Depends(get_db) ) -> Any: """Delete an organization and all its relationships.""" try: org = organization_crud.get(db, id=org_id) if not org: raise NotFoundError( detail=f"Organization {org_id} not found", error_code=ErrorCode.NOT_FOUND ) organization_crud.remove(db, id=org_id) logger.info(f"Admin {admin.email} deleted organization {org.name}") return MessageResponse( success=True, message=f"Organization {org.name} has been deleted" ) except NotFoundError: raise except Exception as e: logger.error(f"Error deleting organization (admin): {str(e)}", exc_info=True) raise @router.get( "/organizations/{org_id}/members", response_model=PaginatedResponse[OrganizationMemberResponse], summary="Admin: List Organization Members", description="Get all members of an organization (admin only)", operation_id="admin_list_organization_members" ) def admin_list_organization_members( org_id: UUID, pagination: PaginationParams = Depends(), is_active: Optional[bool] = Query(True, description="Filter by active status"), admin: User = Depends(require_superuser), db: Session = Depends(get_db) ) -> Any: """List all members of an organization.""" try: org = organization_crud.get(db, id=org_id) if not org: raise NotFoundError( detail=f"Organization {org_id} not found", error_code=ErrorCode.NOT_FOUND ) members, total = organization_crud.get_organization_members( db, organization_id=org_id, skip=pagination.offset, limit=pagination.limit, is_active=is_active ) # Convert to response models member_responses = [OrganizationMemberResponse(**member) for member in members] pagination_meta = create_pagination_meta( total=total, page=pagination.page, limit=pagination.limit, items_count=len(member_responses) ) return PaginatedResponse(data=member_responses, pagination=pagination_meta) except NotFoundError: raise except Exception as e: logger.error(f"Error listing organization members (admin): {str(e)}", exc_info=True) raise class AddMemberRequest(BaseModel): """Request to add a member to an organization.""" user_id: UUID = Field(..., description="User ID to add") role: OrganizationRole = Field(OrganizationRole.MEMBER, description="Role in organization") @router.post( "/organizations/{org_id}/members", response_model=MessageResponse, summary="Admin: Add Member to Organization", description="Add a user to an organization (admin only)", operation_id="admin_add_organization_member" ) def admin_add_organization_member( org_id: UUID, request: AddMemberRequest, admin: User = Depends(require_superuser), db: Session = Depends(get_db) ) -> Any: """Add a user to an organization.""" try: org = organization_crud.get(db, id=org_id) if not org: raise NotFoundError( detail=f"Organization {org_id} not found", error_code=ErrorCode.NOT_FOUND ) user = user_crud.get(db, id=request.user_id) if not user: raise NotFoundError( detail=f"User {request.user_id} not found", error_code=ErrorCode.USER_NOT_FOUND ) organization_crud.add_user( db, organization_id=org_id, user_id=request.user_id, role=request.role ) logger.info( f"Admin {admin.email} added user {user.email} to organization {org.name} " f"with role {request.role.value}" ) return MessageResponse( success=True, message=f"User {user.email} added to organization {org.name}" ) except ValueError as e: logger.warning(f"Failed to add user to organization: {str(e)}") raise NotFoundError(detail=str(e), error_code=ErrorCode.ALREADY_EXISTS) except NotFoundError: raise except Exception as e: logger.error(f"Error adding member to organization (admin): {str(e)}", exc_info=True) raise @router.delete( "/organizations/{org_id}/members/{user_id}", response_model=MessageResponse, summary="Admin: Remove Member from Organization", description="Remove a user from an organization (admin only)", operation_id="admin_remove_organization_member" ) def admin_remove_organization_member( org_id: UUID, user_id: UUID, admin: User = Depends(require_superuser), db: Session = Depends(get_db) ) -> Any: """Remove a user from an organization.""" try: org = organization_crud.get(db, id=org_id) if not org: raise NotFoundError( detail=f"Organization {org_id} not found", error_code=ErrorCode.NOT_FOUND ) user = user_crud.get(db, id=user_id) if not user: raise NotFoundError( detail=f"User {user_id} not found", error_code=ErrorCode.USER_NOT_FOUND ) success = organization_crud.remove_user( db, organization_id=org_id, user_id=user_id ) if not success: raise NotFoundError( detail="User is not a member of this organization", error_code=ErrorCode.NOT_FOUND ) logger.info(f"Admin {admin.email} removed user {user.email} from organization {org.name}") return MessageResponse( success=True, message=f"User {user.email} removed from organization {org.name}" ) except NotFoundError: raise except Exception as e: logger.error(f"Error removing member from organization (admin): {str(e)}", exc_info=True) raise