# services/recipes/app/api/recipes.py """ API endpoints for recipe management """ from fastapi import APIRouter, Depends, HTTPException, Header, Query 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, RecipeSearchRequest, RecipeDuplicateRequest, RecipeFeasibilityResponse, RecipeStatisticsResponse, RecipeQualityConfiguration, RecipeQualityConfigurationUpdate ) logger = logging.getLogger(__name__) router = APIRouter() 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("/{tenant_id}/recipes", response_model=RecipeResponse) async def create_recipe( tenant_id: UUID, recipe_data: RecipeCreate, user_id: UUID = Depends(get_user_id), db: AsyncSession = Depends(get_db) ): """Create a new recipe""" try: recipe_service = RecipeService(db) # Convert Pydantic model to dict 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("/{tenant_id}/recipes/{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") # Verify tenant ownership 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("/{tenant_id}/recipes/{recipe_id}", response_model=RecipeResponse) async def update_recipe( tenant_id: UUID, recipe_id: UUID, recipe_data: RecipeUpdate, user_id: UUID = Depends(get_user_id), db: AsyncSession = Depends(get_db) ): """Update an existing recipe""" try: recipe_service = RecipeService(db) # Check if recipe exists and belongs to tenant 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") # Convert Pydantic model to dict 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("/{tenant_id}/recipes/{recipe_id}") async def delete_recipe( tenant_id: UUID, recipe_id: UUID, db: AsyncSession = Depends(get_db) ): """Delete a recipe""" try: recipe_service = RecipeService(db) # Check if recipe exists and belongs to tenant 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") # Use service to delete success = await recipe_service.delete_recipe(recipe_id) if not success: raise HTTPException(status_code=404, detail="Recipe not found") 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.get("/{tenant_id}/recipes", 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), 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.post("/{tenant_id}/recipes/{recipe_id}/duplicate", response_model=RecipeResponse) 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) # Check if original recipe exists and belongs to tenant 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("/{tenant_id}/recipes/{recipe_id}/activate", response_model=RecipeResponse) 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) # Check if recipe exists and belongs to tenant 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("/{tenant_id}/recipes/{recipe_id}/feasibility", response_model=RecipeFeasibilityResponse) async def check_recipe_feasibility( tenant_id: UUID, recipe_id: UUID, batch_multiplier: float = Query(1.0, gt=0), db: AsyncSession = Depends(get_db) ): """Check if recipe can be produced with current inventory""" try: recipe_service = RecipeService(db) # Check if recipe exists and belongs to tenant 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("/{tenant_id}/recipes/statistics/dashboard", response_model=RecipeStatisticsResponse) async def get_recipe_statistics( tenant_id: UUID, 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("/{tenant_id}/recipes/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) # Get categories from existing recipes 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") # Quality Configuration Endpoints @router.get("/{tenant_id}/recipes/{recipe_id}/quality-configuration", response_model=RecipeQualityConfiguration) async def get_recipe_quality_configuration( tenant_id: UUID, recipe_id: UUID, db: AsyncSession = Depends(get_db) ): """Get quality configuration for a specific recipe""" try: recipe_service = RecipeService(db) # Get recipe with quality configuration recipe = await recipe_service.get_recipe(tenant_id, recipe_id) if not recipe: raise HTTPException(status_code=404, detail="Recipe not found") # Return quality configuration or default structure quality_config = recipe.get("quality_check_configuration") if not quality_config: quality_config = { "stages": {}, "overall_quality_threshold": 7.0, "critical_stage_blocking": True, "auto_create_quality_checks": True, "quality_manager_approval_required": False } return quality_config except HTTPException: raise except Exception as e: logger.error(f"Error getting recipe quality configuration: {e}") raise HTTPException(status_code=500, detail="Internal server error") @router.put("/{tenant_id}/recipes/{recipe_id}/quality-configuration", response_model=RecipeQualityConfiguration) async def update_recipe_quality_configuration( tenant_id: UUID, recipe_id: UUID, quality_config: RecipeQualityConfigurationUpdate, user_id: UUID = Depends(get_user_id), db: AsyncSession = Depends(get_db) ): """Update quality configuration for a specific recipe""" try: recipe_service = RecipeService(db) # Verify recipe exists and belongs to tenant recipe = await recipe_service.get_recipe(tenant_id, recipe_id) if not recipe: raise HTTPException(status_code=404, detail="Recipe not found") # Update recipe with quality configuration updated_recipe = await recipe_service.update_recipe_quality_configuration( tenant_id, recipe_id, quality_config.dict(exclude_unset=True), user_id ) return updated_recipe["quality_check_configuration"] except HTTPException: raise except Exception as e: logger.error(f"Error updating recipe quality configuration: {e}") raise HTTPException(status_code=500, detail="Internal server error") @router.post("/{tenant_id}/recipes/{recipe_id}/quality-configuration/stages/{stage}/templates") async def add_quality_templates_to_stage( tenant_id: UUID, recipe_id: UUID, stage: str, template_ids: List[UUID], user_id: UUID = Depends(get_user_id), db: AsyncSession = Depends(get_db) ): """Add quality templates to a specific recipe stage""" try: recipe_service = RecipeService(db) # Verify recipe exists recipe = await recipe_service.get_recipe(tenant_id, recipe_id) if not recipe: raise HTTPException(status_code=404, detail="Recipe not found") # Add templates to stage await recipe_service.add_quality_templates_to_stage( tenant_id, recipe_id, stage, template_ids, user_id ) return {"message": f"Added {len(template_ids)} templates to {stage} stage"} except HTTPException: raise except Exception as e: logger.error(f"Error adding quality templates to recipe stage: {e}") raise HTTPException(status_code=500, detail="Internal server error") @router.delete("/{tenant_id}/recipes/{recipe_id}/quality-configuration/stages/{stage}/templates/{template_id}") async def remove_quality_template_from_stage( tenant_id: UUID, recipe_id: UUID, stage: str, template_id: UUID, user_id: UUID = Depends(get_user_id), db: AsyncSession = Depends(get_db) ): """Remove a quality template from a specific recipe stage""" try: recipe_service = RecipeService(db) # Verify recipe exists recipe = await recipe_service.get_recipe(tenant_id, recipe_id) if not recipe: raise HTTPException(status_code=404, detail="Recipe not found") # Remove template from stage await recipe_service.remove_quality_template_from_stage( tenant_id, recipe_id, stage, template_id, user_id ) return {"message": f"Removed template from {stage} stage"} except HTTPException: raise except Exception as e: logger.error(f"Error removing quality template from recipe stage: {e}") raise HTTPException(status_code=500, detail="Internal server error")