392 lines
13 KiB
Python
392 lines
13 KiB
Python
# 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 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")
|
|
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")
|