From 9e07ce393f2ce0ba4c7ac5829f4e5b58b8d45ecc Mon Sep 17 00:00:00 2001 From: JSC Date: Sat, 9 Aug 2025 22:37:51 +0200 Subject: [PATCH] feat: Implement admin user management endpoints and user update schema --- app/api/v1/admin/__init__.py | 3 +- app/api/v1/admin/users.py | 148 +++++++++++++++++++++++++++++++++++ app/main.py | 4 +- app/repositories/plan.py | 17 ++++ app/repositories/user.py | 37 +++++++++ app/schemas/user.py | 14 ++++ 6 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 app/api/v1/admin/users.py create mode 100644 app/repositories/plan.py create mode 100644 app/schemas/user.py diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py index bd24a55..5c2c751 100644 --- a/app/api/v1/admin/__init__.py +++ b/app/api/v1/admin/__init__.py @@ -2,10 +2,11 @@ from fastapi import APIRouter -from app.api.v1.admin import extractions, sounds +from app.api.v1.admin import extractions, sounds, users router = APIRouter(prefix="/admin") # Include all admin sub-routers router.include_router(extractions.router) router.include_router(sounds.router) +router.include_router(users.router) diff --git a/app/api/v1/admin/users.py b/app/api/v1/admin/users.py new file mode 100644 index 0000000..f242ede --- /dev/null +++ b/app/api/v1/admin/users.py @@ -0,0 +1,148 @@ +"""Admin users endpoints.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.core.database import get_db +from app.core.dependencies import get_admin_user +from app.models.plan import Plan +from app.models.user import User +from app.repositories.plan import PlanRepository +from app.repositories.user import UserRepository +from app.schemas.auth import UserResponse +from app.schemas.user import UserUpdate + +router = APIRouter( + prefix="/users", + tags=["admin-users"], + dependencies=[Depends(get_admin_user)], +) + + +def _user_to_response(user: User) -> UserResponse: + """Convert User model to UserResponse.""" + return UserResponse( + id=user.id, + email=user.email, + name=user.name, + picture=user.picture, + role=user.role, + credits=user.credits, + is_active=user.is_active, + plan={ + "id": user.plan.id, + "name": user.plan.name, + "max_credits": user.plan.max_credits, + "features": [], # Add features if needed + } if user.plan else {}, + created_at=user.created_at, + updated_at=user.updated_at, + ) + + +@router.get("/") +async def list_users( + session: Annotated[AsyncSession, Depends(get_db)], + limit: int = 100, + offset: int = 0, +) -> list[UserResponse]: + """Get all users (admin only).""" + user_repo = UserRepository(session) + users = await user_repo.get_all_with_plan(limit=limit, offset=offset) + return [_user_to_response(user) for user in users] + + +@router.get("/{user_id}") +async def get_user( + user_id: int, + session: Annotated[AsyncSession, Depends(get_db)], +) -> UserResponse: + """Get a specific user by ID (admin only).""" + user_repo = UserRepository(session) + user = await user_repo.get_by_id_with_plan(user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + return _user_to_response(user) + + +@router.patch("/{user_id}") +async def update_user( + user_id: int, + user_update: UserUpdate, + session: Annotated[AsyncSession, Depends(get_db)], +) -> UserResponse: + """Update a user (admin only).""" + user_repo = UserRepository(session) + user = await user_repo.get_by_id_with_plan(user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + update_data = user_update.model_dump(exclude_unset=True) + + # If plan_id is being updated, validate it exists + if "plan_id" in update_data: + plan_repo = PlanRepository(session) + plan = await plan_repo.get_by_id(update_data["plan_id"]) + if not plan: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Plan not found", + ) + + updated_user = await user_repo.update(user, update_data) + # Need to refresh the plan relationship after update + await session.refresh(updated_user, ["plan"]) + return _user_to_response(updated_user) + + +@router.post("/{user_id}/disable") +async def disable_user( + user_id: int, + session: Annotated[AsyncSession, Depends(get_db)], +) -> dict[str, str]: + """Disable a user (admin only).""" + user_repo = UserRepository(session) + user = await user_repo.get_by_id_with_plan(user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + await user_repo.update(user, {"is_active": False}) + return {"message": "User disabled successfully"} + + +@router.post("/{user_id}/enable") +async def enable_user( + user_id: int, + session: Annotated[AsyncSession, Depends(get_db)], +) -> dict[str, str]: + """Enable a user (admin only).""" + user_repo = UserRepository(session) + user = await user_repo.get_by_id_with_plan(user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + await user_repo.update(user, {"is_active": True}) + return {"message": "User enabled successfully"} + + +@router.get("/plans/list") +async def list_plans( + session: Annotated[AsyncSession, Depends(get_db)], +) -> list[Plan]: + """Get all plans for user editing (admin only).""" + plan_repo = PlanRepository(session) + return await plan_repo.get_all() diff --git a/app/main.py b/app/main.py index 66d28e0..776c5e9 100644 --- a/app/main.py +++ b/app/main.py @@ -55,8 +55,8 @@ def create_app() -> FastAPI: lifespan=lifespan, # Configure docs URLs for reverse proxy setup docs_url="/api/docs", # Swagger UI at /api/docs - redoc_url="/api/redoc", # ReDoc at /api/redoc - openapi_url="/api/openapi.json" # OpenAPI schema at /api/openapi.json + redoc_url="/api/redoc", # ReDoc at /api/redoc + openapi_url="/api/openapi.json", # OpenAPI schema at /api/openapi.json ) # Add CORS middleware diff --git a/app/repositories/plan.py b/app/repositories/plan.py new file mode 100644 index 0000000..5e3492e --- /dev/null +++ b/app/repositories/plan.py @@ -0,0 +1,17 @@ +"""Plan repository.""" + +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.core.logging import get_logger +from app.models.plan import Plan +from app.repositories.base import BaseRepository + +logger = get_logger(__name__) + + +class PlanRepository(BaseRepository[Plan]): + """Repository for plan operations.""" + + def __init__(self, session: AsyncSession) -> None: + """Initialize the plan repository.""" + super().__init__(Plan, session) diff --git a/app/repositories/user.py b/app/repositories/user.py index 1ac4bcb..4a31045 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -4,6 +4,7 @@ from typing import Any from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession +from sqlalchemy.orm import selectinload from app.core.logging import get_logger from app.models.plan import Plan @@ -20,6 +21,42 @@ class UserRepository(BaseRepository[User]): """Initialize the user repository.""" super().__init__(User, session) + async def get_all_with_plan( + self, + limit: int = 100, + offset: int = 0, + ) -> list[User]: + """Get all users with plan relationship loaded.""" + try: + statement = ( + select(User) + .options(selectinload(User.plan)) + .limit(limit) + .offset(offset) + ) + result = await self.session.exec(statement) + return list(result.all()) + except Exception: + logger.exception("Failed to get all users with plan") + raise + + async def get_by_id_with_plan(self, entity_id: int) -> User | None: + """Get a user by ID with plan relationship loaded.""" + try: + statement = ( + select(User) + .options(selectinload(User.plan)) + .where(User.id == entity_id) + ) + result = await self.session.exec(statement) + return result.first() + except Exception: + logger.exception( + "Failed to get user by ID with plan: %s", + entity_id, + ) + raise + async def get_by_email(self, email: str) -> User | None: """Get a user by email address.""" try: diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..853a7ae --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,14 @@ +"""User schemas.""" + +from pydantic import BaseModel, Field + + +class UserUpdate(BaseModel): + """Schema for updating a user.""" + + name: str | None = Field( + None, min_length=1, max_length=100, description="User full name", + ) + plan_id: int | None = Field(None, description="User plan ID") + credits: int | None = Field(None, ge=0, description="User credits") + is_active: bool | None = Field(None, description="Whether user is active")