Files
bakery-ia/services/recipes/app/api/recipes.py

462 lines
15 KiB
Python
Raw Normal View History

# services/recipes/app/api/recipes.py
"""
2025-10-06 15:27:01 +02:00
Recipes API - Atomic CRUD operations on Recipe model
"""
2025-10-27 16:33:26 +01:00
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,
)
2025-10-29 06:58:05 +01:00
from ..models import AuditLog
2025-10-06 15:27:01 +02:00
from shared.routing import RouteBuilder, RouteCategory
2025-10-31 18:57:58 +01:00
from shared.auth.access_control import require_user_role, service_only_access
2025-10-27 16:33:26 +01:00
from shared.auth.decorators import get_current_user_dep
from shared.security import create_audit_logger, AuditSeverity, AuditAction
2025-10-31 18:57:58 +01:00
from shared.services.tenant_deletion import TenantDataDeletionResult
2025-10-06 15:27:01 +02:00
route_builder = RouteBuilder('recipes')
logger = logging.getLogger(__name__)
2025-10-29 06:58:05 +01:00
audit_logger = create_audit_logger("recipes-service", AuditLog)
2025-10-06 15:27:01 +02:00
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")
2025-10-06 15:27:01 +02:00
@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),
2025-10-27 16:33:26 +01:00
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Create a new recipe"""
try:
recipe_service = RecipeService(db)
2025-10-06 15:27:01 +02:00
recipe_dict = recipe_data.dict(exclude={"ingredients"})
recipe_dict["tenant_id"] = tenant_id
2025-10-06 15:27:01 +02:00
ingredients_list = [ing.dict() for ing in recipe_data.ingredients]
2025-10-06 15:27:01 +02:00
result = await recipe_service.create_recipe(
recipe_dict,
ingredients_list,
user_id
)
2025-10-06 15:27:01 +02:00
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
2025-10-06 15:27:01 +02:00
return RecipeResponse(**result["data"])
except HTTPException:
raise
except Exception as e:
2025-10-06 15:27:01 +02:00
logger.error(f"Error creating recipe: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
2025-10-06 15:27:01 +02:00
@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),
2025-10-27 16:33:26 +01:00
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
)
2025-10-06 15:27:01 +02:00
return [RecipeResponse(**recipe) for recipe in recipes]
2025-10-06 15:27:01 +02:00
except Exception as e:
logger.error(f"Error searching recipes: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
2025-10-24 13:05:04 +02:00
@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:
2025-10-27 16:33:26 +01:00
logger.error(f"Error counting recipes for tenant: {e}")
2025-10-24 13:05:04 +02:00
raise HTTPException(status_code=500, detail="Internal server error")
2025-10-06 15:27:01 +02:00
@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)
):
2025-10-06 15:27:01 +02:00
"""Get recipe by ID with ingredients"""
try:
recipe_service = RecipeService(db)
2025-10-27 16:33:26 +01:00
2025-10-06 15:27:01 +02:00
recipe = await recipe_service.get_recipe_with_ingredients(recipe_id)
if not recipe:
raise HTTPException(status_code=404, detail="Recipe not found")
2025-10-06 15:27:01 +02:00
if recipe["tenant_id"] != str(tenant_id):
raise HTTPException(status_code=403, detail="Access denied")
2025-10-06 15:27:01 +02:00
return RecipeResponse(**recipe)
except HTTPException:
raise
except Exception as e:
2025-10-06 15:27:01 +02:00
logger.error(f"Error getting recipe {recipe_id}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
2025-10-06 15:27:01 +02:00
@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,
2025-10-06 15:27:01 +02:00
recipe_data: RecipeUpdate,
user_id: UUID = Depends(get_user_id),
2025-10-27 16:33:26 +01:00
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
2025-10-06 15:27:01 +02:00
"""Update an existing recipe"""
try:
recipe_service = RecipeService(db)
2025-10-06 15:27:01 +02:00
existing_recipe = await recipe_service.get_recipe_with_ingredients(recipe_id)
if not existing_recipe:
raise HTTPException(status_code=404, detail="Recipe not found")
2025-10-06 15:27:01 +02:00
if existing_recipe["tenant_id"] != str(tenant_id):
raise HTTPException(status_code=403, detail="Access denied")
2025-10-06 15:27:01 +02:00
recipe_dict = recipe_data.dict(exclude={"ingredients"}, exclude_unset=True)
2025-10-06 15:27:01 +02:00
ingredients_list = None
if recipe_data.ingredients is not None:
ingredients_list = [ing.dict() for ing in recipe_data.ingredients]
2025-09-24 21:54:49 +02:00
2025-10-06 15:27:01 +02:00
result = await recipe_service.update_recipe(
recipe_id,
recipe_dict,
ingredients_list,
user_id
)
2025-09-24 21:54:49 +02:00
2025-10-06 15:27:01 +02:00
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
2025-09-24 21:54:49 +02:00
2025-10-06 15:27:01 +02:00
return RecipeResponse(**result["data"])
2025-09-24 21:54:49 +02:00
except HTTPException:
raise
except Exception as e:
2025-10-06 15:27:01 +02:00
logger.error(f"Error updating recipe {recipe_id}: {e}")
2025-09-24 21:54:49 +02:00
raise HTTPException(status_code=500, detail="Internal server error")
2025-10-06 15:27:01 +02:00
@router.delete(
route_builder.build_custom_route(RouteCategory.BASE, ["{recipe_id}"])
)
@require_user_role(['admin', 'owner'])
async def delete_recipe(
2025-09-24 21:54:49 +02:00
tenant_id: UUID,
recipe_id: UUID,
user_id: UUID = Depends(get_user_id),
2025-10-27 16:33:26 +01:00
current_user: dict = Depends(get_current_user_dep),
2025-09-24 21:54:49 +02:00
db: AsyncSession = Depends(get_db)
):
"""Delete a recipe (Admin+ only)"""
2025-09-24 21:54:49 +02:00
try:
recipe_service = RecipeService(db)
2025-10-06 15:27:01 +02:00
existing_recipe = await recipe_service.get_recipe_with_ingredients(recipe_id)
if not existing_recipe:
2025-09-24 21:54:49 +02:00
raise HTTPException(status_code=404, detail="Recipe not found")
2025-10-06 15:27:01 +02:00
if existing_recipe["tenant_id"] != str(tenant_id):
raise HTTPException(status_code=403, detail="Access denied")
2025-09-24 21:54:49 +02:00
2025-10-27 16:33:26 +01:00
# 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", []))
}
2025-10-06 15:27:01 +02:00
success = await recipe_service.delete_recipe(recipe_id)
if not success:
2025-09-24 21:54:49 +02:00
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}")
2025-10-06 15:27:01 +02:00
return {"message": "Recipe deleted successfully"}
2025-09-24 21:54:49 +02:00
except HTTPException:
raise
except Exception as e:
2025-10-06 15:27:01 +02:00
logger.error(f"Error deleting recipe {recipe_id}: {e}")
2025-09-24 21:54:49 +02:00
raise HTTPException(status_code=500, detail="Internal server error")
2025-10-27 16:33:26 +01:00
@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")
2025-10-31 11:54:19 +01:00
# ===== Tenant Data Deletion Endpoints =====
@router.delete("/tenant/{tenant_id}")
2025-10-31 18:57:58 +01:00
@service_only_access
2025-10-31 11:54:19 +01:00
async def delete_tenant_data(
tenant_id: str,
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Delete all recipe-related data for a tenant
Only accessible by internal services (called during tenant deletion)
"""
logger.info(f"Tenant data deletion request received for tenant: {tenant_id}")
try:
from app.services.tenant_deletion_service import RecipesTenantDeletionService
deletion_service = RecipesTenantDeletionService(db)
result = await deletion_service.safe_delete_tenant_data(tenant_id)
return {
"message": "Tenant data deletion completed in recipes-service",
"summary": result.to_dict()
}
except Exception as e:
logger.error(f"Tenant data deletion failed for {tenant_id}: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to delete tenant data: {str(e)}"
)
@router.get("/tenant/{tenant_id}/deletion-preview")
2025-10-31 18:57:58 +01:00
@service_only_access
2025-10-31 11:54:19 +01:00
async def preview_tenant_data_deletion(
tenant_id: str,
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Preview what data would be deleted for a tenant (dry-run)
Accessible by internal services and tenant admins
"""
try:
from app.services.tenant_deletion_service import RecipesTenantDeletionService
deletion_service = RecipesTenantDeletionService(db)
preview = await deletion_service.get_tenant_data_preview(tenant_id)
return {
"tenant_id": tenant_id,
"service": "recipes-service",
"data_counts": preview,
"total_items": sum(preview.values())
}
except Exception as e:
logger.error(f"Deletion preview failed for {tenant_id}: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to get deletion preview: {str(e)}"
)