# services/recipes/app/api/recipes.py """ Recipes API - Atomic CRUD operations on Recipe model """ from fastapi import APIRouter, Depends, HTTPException, Header, Query, Request from sqlalchemy.ext.asyncio import AsyncSession from typing import List, Optional from uuid import UUID import logging from ..core.database import get_db from ..services.recipe_service import RecipeService from ..schemas.recipes import ( RecipeCreate, RecipeUpdate, RecipeResponse, ) from ..models import AuditLog from shared.routing import RouteBuilder, RouteCategory from shared.auth.access_control import require_user_role from shared.auth.decorators import get_current_user_dep from shared.security import create_audit_logger, AuditSeverity, AuditAction route_builder = RouteBuilder('recipes') logger = logging.getLogger(__name__) audit_logger = create_audit_logger("recipes-service", AuditLog) router = APIRouter(tags=["recipes"]) def get_user_id(x_user_id: str = Header(...)) -> UUID: """Extract user ID from header""" try: return UUID(x_user_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid user ID format") @router.post( route_builder.build_custom_route(RouteCategory.BASE, []), response_model=RecipeResponse ) @require_user_role(['admin', 'owner', 'member']) async def create_recipe( tenant_id: UUID, recipe_data: RecipeCreate, user_id: UUID = Depends(get_user_id), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Create a new recipe""" try: recipe_service = RecipeService(db) recipe_dict = recipe_data.dict(exclude={"ingredients"}) recipe_dict["tenant_id"] = tenant_id ingredients_list = [ing.dict() for ing in recipe_data.ingredients] result = await recipe_service.create_recipe( recipe_dict, ingredients_list, user_id ) if not result["success"]: raise HTTPException(status_code=400, detail=result["error"]) return RecipeResponse(**result["data"]) except HTTPException: raise except Exception as e: logger.error(f"Error creating recipe: {e}") raise HTTPException(status_code=500, detail="Internal server error") @router.get( route_builder.build_custom_route(RouteCategory.BASE, []), response_model=List[RecipeResponse] ) async def search_recipes( tenant_id: UUID, search_term: Optional[str] = Query(None), status: Optional[str] = Query(None), category: Optional[str] = Query(None), is_seasonal: Optional[bool] = Query(None), is_signature: Optional[bool] = Query(None), difficulty_level: Optional[int] = Query(None, ge=1, le=5), limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Search recipes with filters""" try: recipe_service = RecipeService(db) recipes = await recipe_service.search_recipes( tenant_id=tenant_id, search_term=search_term, status=status, category=category, is_seasonal=is_seasonal, is_signature=is_signature, difficulty_level=difficulty_level, limit=limit, offset=offset ) return [RecipeResponse(**recipe) for recipe in recipes] except Exception as e: logger.error(f"Error searching recipes: {e}") raise HTTPException(status_code=500, detail="Internal server error") @router.get( route_builder.build_custom_route(RouteCategory.BASE, ["count"]), response_model=dict ) async def count_recipes( tenant_id: UUID, db: AsyncSession = Depends(get_db) ): """Get count of recipes for a tenant""" try: recipe_service = RecipeService(db) # Use the search method with limit 0 to just get the count recipes = await recipe_service.search_recipes( tenant_id=tenant_id, limit=10000 # High limit to get all ) count = len(recipes) logger.info(f"Retrieved recipe count for tenant {tenant_id}: {count}") return {"count": count} except Exception as e: logger.error(f"Error counting recipes for tenant: {e}") raise HTTPException(status_code=500, detail="Internal server error") @router.get( route_builder.build_custom_route(RouteCategory.BASE, ["{recipe_id}"]), response_model=RecipeResponse ) async def get_recipe( tenant_id: UUID, recipe_id: UUID, db: AsyncSession = Depends(get_db) ): """Get recipe by ID with ingredients""" try: recipe_service = RecipeService(db) recipe = await recipe_service.get_recipe_with_ingredients(recipe_id) if not recipe: raise HTTPException(status_code=404, detail="Recipe not found") if recipe["tenant_id"] != str(tenant_id): raise HTTPException(status_code=403, detail="Access denied") return RecipeResponse(**recipe) except HTTPException: raise except Exception as e: logger.error(f"Error getting recipe {recipe_id}: {e}") raise HTTPException(status_code=500, detail="Internal server error") @router.put( route_builder.build_custom_route(RouteCategory.BASE, ["{recipe_id}"]), response_model=RecipeResponse ) @require_user_role(['admin', 'owner', 'member']) async def update_recipe( tenant_id: UUID, recipe_id: UUID, recipe_data: RecipeUpdate, user_id: UUID = Depends(get_user_id), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Update an existing recipe""" try: recipe_service = RecipeService(db) existing_recipe = await recipe_service.get_recipe_with_ingredients(recipe_id) if not existing_recipe: raise HTTPException(status_code=404, detail="Recipe not found") if existing_recipe["tenant_id"] != str(tenant_id): raise HTTPException(status_code=403, detail="Access denied") recipe_dict = recipe_data.dict(exclude={"ingredients"}, exclude_unset=True) ingredients_list = None if recipe_data.ingredients is not None: ingredients_list = [ing.dict() for ing in recipe_data.ingredients] result = await recipe_service.update_recipe( recipe_id, recipe_dict, ingredients_list, user_id ) if not result["success"]: raise HTTPException(status_code=400, detail=result["error"]) return RecipeResponse(**result["data"]) except HTTPException: raise except Exception as e: logger.error(f"Error updating recipe {recipe_id}: {e}") raise HTTPException(status_code=500, detail="Internal server error") @router.delete( route_builder.build_custom_route(RouteCategory.BASE, ["{recipe_id}"]) ) @require_user_role(['admin', 'owner']) async def delete_recipe( tenant_id: UUID, recipe_id: UUID, user_id: UUID = Depends(get_user_id), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Delete a recipe (Admin+ only)""" try: recipe_service = RecipeService(db) existing_recipe = await recipe_service.get_recipe_with_ingredients(recipe_id) if not existing_recipe: raise HTTPException(status_code=404, detail="Recipe not found") if existing_recipe["tenant_id"] != str(tenant_id): raise HTTPException(status_code=403, detail="Access denied") # Check if deletion is safe summary = await recipe_service.get_deletion_summary(recipe_id) if not summary["success"]: raise HTTPException(status_code=500, detail=summary["error"]) if not summary["data"]["can_delete"]: raise HTTPException( status_code=400, detail={ "message": "Cannot delete recipe with active dependencies", "warnings": summary["data"]["warnings"] } ) # Capture recipe data before deletion recipe_data = { "recipe_name": existing_recipe.get("name"), "category": existing_recipe.get("category"), "difficulty_level": existing_recipe.get("difficulty_level"), "ingredient_count": len(existing_recipe.get("ingredients", [])) } success = await recipe_service.delete_recipe(recipe_id) if not success: raise HTTPException(status_code=404, detail="Recipe not found") # Log audit event for recipe deletion try: # Get sync db for audit logging from ..core.database import SessionLocal sync_db = SessionLocal() try: await audit_logger.log_deletion( db_session=sync_db, tenant_id=str(tenant_id), user_id=str(user_id), resource_type="recipe", resource_id=str(recipe_id), resource_data=recipe_data, description=f"Admin deleted recipe {recipe_data['recipe_name']}", endpoint=f"/recipes/{recipe_id}", method="DELETE" ) sync_db.commit() finally: sync_db.close() except Exception as audit_error: logger.warning(f"Failed to log audit event: {audit_error}") logger.info(f"Deleted recipe {recipe_id} by user {user_id}") return {"message": "Recipe deleted successfully"} except HTTPException: raise except Exception as e: logger.error(f"Error deleting recipe {recipe_id}: {e}") raise HTTPException(status_code=500, detail="Internal server error") @router.patch( route_builder.build_custom_route(RouteCategory.OPERATIONS, ["{recipe_id}", "archive"]) ) @require_user_role(['admin', 'owner']) async def archive_recipe( tenant_id: UUID, recipe_id: UUID, user_id: UUID = Depends(get_user_id), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Archive (soft delete) a recipe by setting status to ARCHIVED""" try: recipe_service = RecipeService(db) existing_recipe = await recipe_service.get_recipe_with_ingredients(recipe_id) if not existing_recipe: raise HTTPException(status_code=404, detail="Recipe not found") if existing_recipe["tenant_id"] != str(tenant_id): raise HTTPException(status_code=403, detail="Not authorized") # Check status transitions (business rule) current_status = existing_recipe.get("status") if current_status == "DISCONTINUED": raise HTTPException( status_code=400, detail="Cannot archive a discontinued recipe. Use hard delete instead." ) # Update status to ARCHIVED from ..schemas.recipes import RecipeUpdate, RecipeStatus update_data = RecipeUpdate(status=RecipeStatus.ARCHIVED) updated_recipe = await recipe_service.update_recipe( recipe_id, update_data.dict(exclude_unset=True), user_id ) if not updated_recipe["success"]: raise HTTPException(status_code=400, detail=updated_recipe["error"]) logger.info(f"Archived recipe {recipe_id} by user {user_id}") return RecipeResponse(**updated_recipe["data"]) except HTTPException: raise except Exception as e: logger.error(f"Error archiving recipe: {e}") raise HTTPException(status_code=500, detail="Internal server error") @router.get( route_builder.build_custom_route(RouteCategory.OPERATIONS, ["{recipe_id}", "deletion-summary"]) ) @require_user_role(['admin', 'owner']) async def get_recipe_deletion_summary( tenant_id: UUID, recipe_id: UUID, current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Get summary of what will be affected by deleting this recipe""" try: recipe_service = RecipeService(db) existing_recipe = await recipe_service.get_recipe_with_ingredients(recipe_id) if not existing_recipe: raise HTTPException(status_code=404, detail="Recipe not found") if existing_recipe["tenant_id"] != str(tenant_id): raise HTTPException(status_code=403, detail="Not authorized") summary = await recipe_service.get_deletion_summary(recipe_id) if not summary["success"]: raise HTTPException(status_code=500, detail=summary["error"]) return summary["data"] except HTTPException: raise except Exception as e: logger.error(f"Error getting deletion summary: {e}") raise HTTPException(status_code=500, detail="Internal server error")