# services/recipes/app/api/recipe_operations.py """ Recipe Operations API - Business operations and complex workflows """ from fastapi import APIRouter, Depends, HTTPException, Header, Query, Path from sqlalchemy.ext.asyncio import AsyncSession from uuid import UUID import logging from ..core.database import get_db from ..services.recipe_service import RecipeService from ..schemas.recipes import ( RecipeResponse, RecipeDuplicateRequest, RecipeFeasibilityResponse, RecipeStatisticsResponse, ) from shared.routing import RouteBuilder, RouteCategory from shared.auth.access_control import require_user_role, analytics_tier_required from shared.auth.decorators import get_current_user_dep route_builder = RouteBuilder('recipes') logger = logging.getLogger(__name__) router = APIRouter(tags=["recipe-operations"]) 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, ["{recipe_id}", "duplicate"]), response_model=RecipeResponse ) @require_user_role(['admin', 'owner', 'member']) async def duplicate_recipe( tenant_id: UUID, recipe_id: UUID, duplicate_data: RecipeDuplicateRequest, user_id: UUID = Depends(get_user_id), db: AsyncSession = Depends(get_db) ): """Create a duplicate of an existing recipe""" try: recipe_service = RecipeService(db) existing_recipe = 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") result = await recipe_service.duplicate_recipe( recipe_id, duplicate_data.new_name, 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 duplicating recipe {recipe_id}: {e}") raise HTTPException(status_code=500, detail="Internal server error") @router.post( route_builder.build_custom_route(RouteCategory.BASE, ["{recipe_id}", "activate"]), response_model=RecipeResponse ) @require_user_role(['admin', 'owner', 'member']) async def activate_recipe( tenant_id: UUID, recipe_id: UUID, user_id: UUID = Depends(get_user_id), db: AsyncSession = Depends(get_db) ): """Activate a recipe for production""" 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") result = await recipe_service.activate_recipe(recipe_id, 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 activating recipe {recipe_id}: {e}") raise HTTPException(status_code=500, detail="Internal server error") @router.get( route_builder.build_custom_route(RouteCategory.BASE, ["{recipe_id}", "feasibility"]), response_model=RecipeFeasibilityResponse ) @analytics_tier_required async def check_recipe_feasibility( tenant_id: UUID, recipe_id: UUID, batch_multiplier: float = Query(1.0, gt=0), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """ Check if recipe can be produced with current inventory (Professional+ tier) Supports batch scaling for production planning """ 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") result = await recipe_service.check_recipe_feasibility(recipe_id, batch_multiplier) if not result["success"]: raise HTTPException(status_code=400, detail=result["error"]) return RecipeFeasibilityResponse(**result["data"]) except HTTPException: raise except Exception as e: logger.error(f"Error checking recipe feasibility {recipe_id}: {e}") raise HTTPException(status_code=500, detail="Internal server error") @router.get( route_builder.build_dashboard_route("statistics"), response_model=RecipeStatisticsResponse ) @require_user_role(['viewer', 'member', 'admin', 'owner']) async def get_recipe_statistics( tenant_id: UUID, current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Get recipe statistics for dashboard""" try: recipe_service = RecipeService(db) stats = await recipe_service.get_recipe_statistics(tenant_id) return RecipeStatisticsResponse(**stats) except Exception as e: logger.error(f"Error getting recipe statistics: {e}") raise HTTPException(status_code=500, detail="Internal server error") @router.get( route_builder.build_custom_route(RouteCategory.BASE, ["categories", "list"]) ) async def get_recipe_categories( tenant_id: UUID, db: AsyncSession = Depends(get_db) ): """Get list of recipe categories used by tenant""" try: recipe_service = RecipeService(db) recipes = await recipe_service.search_recipes(tenant_id, limit=1000) categories = list(set(recipe["category"] for recipe in recipes if recipe["category"])) categories.sort() return {"categories": categories} except Exception as e: logger.error(f"Error getting recipe categories: {e}") raise HTTPException(status_code=500, detail="Internal server error") @router.get( route_builder.build_custom_route(RouteCategory.BASE, ["count"]) ) async def get_recipe_count( tenant_id: UUID, x_internal_request: str = Header(None), db: AsyncSession = Depends(get_db) ): """ Get total count of recipes for a tenant Internal endpoint for subscription usage tracking """ if x_internal_request != "true": raise HTTPException(status_code=403, detail="Internal endpoint only") try: recipe_service = RecipeService(db) recipes = await recipe_service.search_recipes(tenant_id, limit=10000) count = len(recipes) return {"count": count} except Exception as e: logger.error(f"Error getting recipe count: {e}") raise HTTPException(status_code=500, detail="Internal server error") # ============================================================================ # Tenant Data Deletion Operations (Internal Service Only) # ============================================================================ from shared.auth.access_control import service_only_access from shared.services.tenant_deletion import TenantDataDeletionResult from app.services.tenant_deletion_service import RecipesTenantDeletionService @router.delete( route_builder.build_base_route("tenant/{tenant_id}", include_tenant_prefix=False), response_model=dict ) @service_only_access async def delete_tenant_data( tenant_id: str = Path(..., description="Tenant ID to delete data for"), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """ Delete all recipes data for a tenant (Internal service only) """ try: logger.info("recipes.tenant_deletion.api_called", tenant_id=tenant_id) deletion_service = RecipesTenantDeletionService(db) result = await deletion_service.safe_delete_tenant_data(tenant_id) if not result.success: raise HTTPException( status_code=500, detail=f"Tenant data deletion failed: {', '.join(result.errors)}" ) return { "message": "Tenant data deletion completed successfully", "summary": result.to_dict() } except HTTPException: raise except Exception as e: logger.error(f"recipes.tenant_deletion.api_error - tenant_id: {tenant_id}, error: {str(e)}", exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to delete tenant data: {str(e)}") @router.get( route_builder.build_base_route("tenant/{tenant_id}/deletion-preview", include_tenant_prefix=False), response_model=dict ) @service_only_access async def preview_tenant_data_deletion( tenant_id: str = Path(..., description="Tenant ID to preview deletion for"), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """ Preview what data would be deleted for a tenant (dry-run) """ try: logger.info(f"recipes.tenant_deletion.preview_called - tenant_id: {tenant_id}") deletion_service = RecipesTenantDeletionService(db) preview_data = await deletion_service.get_tenant_data_preview(tenant_id) result = TenantDataDeletionResult(tenant_id=tenant_id, service_name=deletion_service.service_name) result.deleted_counts = preview_data result.success = True if not result.success: raise HTTPException( status_code=500, detail=f"Tenant deletion preview failed: {', '.join(result.errors)}" ) return { "tenant_id": tenant_id, "service": "recipes-service", "data_counts": result.deleted_counts, "total_items": sum(result.deleted_counts.values()) } except HTTPException: raise except Exception as e: logger.error(f"recipes.tenant_deletion.preview_error - tenant_id: {tenant_id}, error: {str(e)}", exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to preview tenant data deletion: {str(e)}")