487 lines
16 KiB
Python
487 lines
16 KiB
Python
# 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") |