Initial commit - production deployment

This commit is contained in:
2026-01-21 17:17:16 +01:00
commit c23d00dd92
2289 changed files with 638440 additions and 0 deletions

View File

@@ -0,0 +1 @@
# services/recipes/app/api/__init__.py

View File

@@ -0,0 +1,237 @@
# services/recipes/app/api/audit.py
"""
Audit Logs API - Retrieve audit trail for recipes service
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
from typing import Optional, Dict, Any
from uuid import UUID
from datetime import datetime
import structlog
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import AuditLog
from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import require_user_role
from shared.routing import RouteBuilder
from shared.models.audit_log_schemas import (
AuditLogResponse,
AuditLogListResponse,
AuditLogStatsResponse
)
from app.core.database import db_manager
route_builder = RouteBuilder('recipes')
router = APIRouter(tags=["audit-logs"])
logger = structlog.get_logger()
async def get_db():
"""Database session dependency"""
async with db_manager.get_session() as session:
yield session
@router.get(
route_builder.build_base_route("audit-logs"),
response_model=AuditLogListResponse
)
@require_user_role(['admin', 'owner'])
async def get_audit_logs(
tenant_id: UUID = Path(..., description="Tenant ID"),
start_date: Optional[datetime] = Query(None, description="Filter logs from this date"),
end_date: Optional[datetime] = Query(None, description="Filter logs until this date"),
user_id: Optional[UUID] = Query(None, description="Filter by user ID"),
action: Optional[str] = Query(None, description="Filter by action type"),
resource_type: Optional[str] = Query(None, description="Filter by resource type"),
severity: Optional[str] = Query(None, description="Filter by severity level"),
search: Optional[str] = Query(None, description="Search in description field"),
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
offset: int = Query(0, ge=0, description="Number of records to skip"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get audit logs for recipes service.
Requires admin or owner role.
"""
try:
logger.info(
"Retrieving audit logs",
tenant_id=tenant_id,
user_id=current_user.get("user_id"),
filters={
"start_date": start_date,
"end_date": end_date,
"action": action,
"resource_type": resource_type,
"severity": severity
}
)
# Build query filters
filters = [AuditLog.tenant_id == tenant_id]
if start_date:
filters.append(AuditLog.created_at >= start_date)
if end_date:
filters.append(AuditLog.created_at <= end_date)
if user_id:
filters.append(AuditLog.user_id == user_id)
if action:
filters.append(AuditLog.action == action)
if resource_type:
filters.append(AuditLog.resource_type == resource_type)
if severity:
filters.append(AuditLog.severity == severity)
if search:
filters.append(AuditLog.description.ilike(f"%{search}%"))
# Count total matching records
count_query = select(func.count()).select_from(AuditLog).where(and_(*filters))
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Fetch paginated results
query = (
select(AuditLog)
.where(and_(*filters))
.order_by(AuditLog.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await db.execute(query)
audit_logs = result.scalars().all()
# Convert to response models
items = [AuditLogResponse.from_orm(log) for log in audit_logs]
logger.info(
"Successfully retrieved audit logs",
tenant_id=tenant_id,
total=total,
returned=len(items)
)
return AuditLogListResponse(
items=items,
total=total,
limit=limit,
offset=offset,
has_more=(offset + len(items)) < total
)
except Exception as e:
logger.error(
"Failed to retrieve audit logs",
error=str(e),
tenant_id=tenant_id
)
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve audit logs: {str(e)}"
)
@router.get(
route_builder.build_base_route("audit-logs/stats"),
response_model=AuditLogStatsResponse
)
@require_user_role(['admin', 'owner'])
async def get_audit_log_stats(
tenant_id: UUID = Path(..., description="Tenant ID"),
start_date: Optional[datetime] = Query(None, description="Filter logs from this date"),
end_date: Optional[datetime] = Query(None, description="Filter logs until this date"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get audit log statistics for recipes service.
Requires admin or owner role.
"""
try:
logger.info(
"Retrieving audit log statistics",
tenant_id=tenant_id,
user_id=current_user.get("user_id")
)
# Build base filters
filters = [AuditLog.tenant_id == tenant_id]
if start_date:
filters.append(AuditLog.created_at >= start_date)
if end_date:
filters.append(AuditLog.created_at <= end_date)
# Total events
count_query = select(func.count()).select_from(AuditLog).where(and_(*filters))
total_result = await db.execute(count_query)
total_events = total_result.scalar() or 0
# Events by action
action_query = (
select(AuditLog.action, func.count().label('count'))
.where(and_(*filters))
.group_by(AuditLog.action)
)
action_result = await db.execute(action_query)
events_by_action = {row.action: row.count for row in action_result}
# Events by severity
severity_query = (
select(AuditLog.severity, func.count().label('count'))
.where(and_(*filters))
.group_by(AuditLog.severity)
)
severity_result = await db.execute(severity_query)
events_by_severity = {row.severity: row.count for row in severity_result}
# Events by resource type
resource_query = (
select(AuditLog.resource_type, func.count().label('count'))
.where(and_(*filters))
.group_by(AuditLog.resource_type)
)
resource_result = await db.execute(resource_query)
events_by_resource_type = {row.resource_type: row.count for row in resource_result}
# Date range
date_range_query = (
select(
func.min(AuditLog.created_at).label('min_date'),
func.max(AuditLog.created_at).label('max_date')
)
.where(and_(*filters))
)
date_result = await db.execute(date_range_query)
date_row = date_result.one()
logger.info(
"Successfully retrieved audit log statistics",
tenant_id=tenant_id,
total_events=total_events
)
return AuditLogStatsResponse(
total_events=total_events,
events_by_action=events_by_action,
events_by_severity=events_by_severity,
events_by_resource_type=events_by_resource_type,
date_range={
"min": date_row.min_date,
"max": date_row.max_date
}
)
except Exception as e:
logger.error(
"Failed to retrieve audit log statistics",
error=str(e),
tenant_id=tenant_id
)
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve audit log statistics: {str(e)}"
)

View File

@@ -0,0 +1,47 @@
"""
Internal API for Recipes Service
Handles internal service-to-service operations
"""
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from uuid import UUID
import structlog
from app.core.database import get_db
from app.core.config import settings
from app.models.recipes import Recipe, RecipeStatus
logger = structlog.get_logger()
router = APIRouter(prefix="/internal", tags=["internal"])
@router.get("/count")
async def get_recipe_count(
tenant_id: str,
db: AsyncSession = Depends(get_db)
):
"""
Get count of recipes for onboarding status check.
Counts DRAFT and ACTIVE recipes (excludes ARCHIVED/DISCONTINUED).
Internal endpoint for tenant service.
"""
try:
count = await db.scalar(
select(func.count()).select_from(Recipe)
.where(
Recipe.tenant_id == UUID(tenant_id),
Recipe.status.in_([RecipeStatus.DRAFT, RecipeStatus.ACTIVE, RecipeStatus.TESTING])
)
)
return {
"count": count or 0,
"tenant_id": tenant_id
}
except Exception as e:
logger.error("Failed to get recipe count", tenant_id=tenant_id, error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get recipe count: {str(e)}")

View File

@@ -0,0 +1,426 @@
"""
Internal Demo Cloning API for Recipes Service
Service-to-service endpoint for cloning recipe and production data
"""
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func
import structlog
import uuid
from uuid import UUID
from datetime import datetime, timezone, timedelta
from typing import Optional
import os
import sys
import json
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from shared.utils.demo_dates import adjust_date_for_demo, resolve_time_marker
from app.core.database import get_db
from app.models.recipes import (
Recipe, RecipeIngredient, ProductionBatch, ProductionIngredientConsumption,
RecipeStatus, ProductionStatus, MeasurementUnit, ProductionPriority
)
from app.core.config import settings
logger = structlog.get_logger()
router = APIRouter(prefix="/internal/demo", tags=["internal"])
# Base demo tenant IDs
DEMO_TENANT_PROFESSIONAL = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
def parse_date_field(
field_value: any,
session_time: datetime,
field_name: str = "date"
) -> Optional[datetime]:
"""
Parse a date field from JSON, supporting BASE_TS markers and ISO timestamps.
Args:
field_value: The date field value (can be BASE_TS marker, ISO string, or None)
session_time: Session creation time (timezone-aware UTC)
field_name: Name of the field (for logging)
Returns:
Timezone-aware UTC datetime or None
"""
if field_value is None:
return None
# Handle BASE_TS markers
if isinstance(field_value, str) and field_value.startswith("BASE_TS"):
try:
return resolve_time_marker(field_value, session_time)
except (ValueError, AttributeError) as e:
logger.warning(
"Failed to resolve BASE_TS marker",
field_name=field_name,
marker=field_value,
error=str(e)
)
return None
# Handle ISO timestamps (legacy format - convert to absolute datetime)
if isinstance(field_value, str) and ('T' in field_value or 'Z' in field_value):
try:
parsed_date = datetime.fromisoformat(field_value.replace('Z', '+00:00'))
# Adjust relative to session time
return adjust_date_for_demo(parsed_date, session_time)
except (ValueError, AttributeError) as e:
logger.warning(
"Failed to parse ISO timestamp",
field_name=field_name,
value=field_value,
error=str(e)
)
return None
logger.warning(
"Unknown date format",
field_name=field_name,
value=field_value,
value_type=type(field_value).__name__
)
return None
@router.post("/clone")
async def clone_demo_data(
base_tenant_id: str,
virtual_tenant_id: str,
demo_account_type: str,
session_id: Optional[str] = None,
session_created_at: Optional[str] = None,
db: AsyncSession = Depends(get_db)
):
"""
Clone recipes service data for a virtual demo tenant
This endpoint creates fresh demo data by:
1. Loading seed data from JSON files
2. Applying XOR-based ID transformation
3. Adjusting dates relative to session creation time
4. Creating records in the virtual tenant
Args:
base_tenant_id: Template tenant UUID (for reference)
virtual_tenant_id: Target virtual tenant UUID
demo_account_type: Type of demo account
session_id: Originating session ID for tracing
session_created_at: Session creation timestamp for date adjustment
Returns:
Cloning status and record counts
"""
start_time = datetime.now(timezone.utc)
try:
# Validate UUIDs
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Parse session creation time for date adjustment
if session_created_at:
try:
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
except (ValueError, AttributeError):
session_time = start_time
else:
session_time = start_time
logger.info(
"Starting recipes data cloning",
base_tenant_id=base_tenant_id,
virtual_tenant_id=virtual_tenant_id,
demo_account_type=demo_account_type,
session_id=session_id,
session_created_at=session_created_at
)
# Load seed data from JSON files
from shared.utils.seed_data_paths import get_seed_data_path
if demo_account_type == "professional":
json_file = get_seed_data_path("professional", "04-recipes.json")
elif demo_account_type == "enterprise":
json_file = get_seed_data_path("enterprise", "04-recipes.json")
elif demo_account_type == "enterprise_child":
json_file = get_seed_data_path("enterprise", "04-recipes.json", child_id=base_tenant_id)
else:
raise ValueError(f"Invalid demo account type: {demo_account_type}")
# Load JSON data
with open(json_file, 'r', encoding='utf-8') as f:
seed_data = json.load(f)
# Track cloning statistics
stats = {
"recipes": 0,
"recipe_ingredients": 0
}
# First, build recipe ID map by processing all recipes
recipe_id_map = {}
# Create Recipes
for recipe_data in seed_data.get('recipes', []):
# Transform recipe ID using XOR
from shared.utils.demo_id_transformer import transform_id
try:
recipe_uuid = uuid.UUID(recipe_data['id'])
transformed_id = transform_id(recipe_data['id'], virtual_uuid)
except ValueError as e:
logger.error("Failed to parse recipe UUID",
recipe_id=recipe_data['id'],
error=str(e))
raise HTTPException(
status_code=400,
detail=f"Invalid UUID format in recipe data: {str(e)}"
)
# Parse date fields (supports BASE_TS markers and ISO timestamps)
adjusted_created_at = parse_date_field(
recipe_data.get('created_at'),
session_time,
"created_at"
)
adjusted_updated_at = parse_date_field(
recipe_data.get('updated_at'),
session_time,
"updated_at"
)
# Map field names from seed data to model fields
# Handle yield_quantity/yield_unit (may be named finished_product_quantity/unit in seed data)
yield_quantity = recipe_data.get('yield_quantity') or recipe_data.get('finished_product_quantity', 1.0)
yield_unit_str = recipe_data.get('yield_unit') or recipe_data.get('finished_product_unit', 'UNITS')
# Convert yield_unit string to enum if needed
if isinstance(yield_unit_str, str):
try:
yield_unit = MeasurementUnit[yield_unit_str.upper()]
except KeyError:
yield_unit = MeasurementUnit.UNITS
else:
yield_unit = yield_unit_str
# Convert status string to enum if needed
status = recipe_data.get('status', 'ACTIVE')
if isinstance(status, str):
try:
status = RecipeStatus[status.upper()]
except KeyError:
status = RecipeStatus.ACTIVE
new_recipe = Recipe(
id=str(transformed_id),
tenant_id=virtual_uuid,
name=recipe_data['name'],
description=recipe_data.get('description'),
recipe_code=recipe_data.get('recipe_code'),
version=recipe_data.get('version', '1.0'),
status=status,
finished_product_id=recipe_data['finished_product_id'],
yield_quantity=yield_quantity,
yield_unit=yield_unit,
category=recipe_data.get('category'),
difficulty_level=recipe_data.get('difficulty_level', 1),
prep_time_minutes=recipe_data.get('prep_time_minutes') or recipe_data.get('preparation_time_minutes'),
cook_time_minutes=recipe_data.get('cook_time_minutes') or recipe_data.get('baking_time_minutes'),
total_time_minutes=recipe_data.get('total_time_minutes'),
rest_time_minutes=recipe_data.get('rest_time_minutes') or recipe_data.get('cooling_time_minutes'),
instructions=recipe_data.get('instructions'),
preparation_notes=recipe_data.get('notes') or recipe_data.get('preparation_notes'),
created_at=adjusted_created_at,
updated_at=adjusted_updated_at
)
db.add(new_recipe)
stats["recipes"] += 1
# Add recipe ID to map for ingredients
recipe_id_map[recipe_data['id']] = str(transformed_id)
# Create Recipe Ingredients
for recipe_ingredient_data in seed_data.get('recipe_ingredients', []):
# Transform ingredient ID using XOR
try:
ingredient_uuid = uuid.UUID(recipe_ingredient_data['id'])
transformed_id = transform_id(ingredient_uuid, virtual_uuid)
except ValueError as e:
logger.error("Failed to parse recipe ingredient UUID",
ingredient_id=recipe_ingredient_data['id'],
error=str(e))
raise HTTPException(
status_code=400,
detail=f"Invalid UUID format in recipe ingredient data: {str(e)}"
)
# Get the transformed recipe ID
recipe_id = recipe_id_map.get(recipe_ingredient_data['recipe_id'])
if not recipe_id:
logger.error("Recipe not found for ingredient",
recipe_id=recipe_ingredient_data['recipe_id'])
continue
# Convert unit string to enum if needed
unit_str = recipe_ingredient_data.get('unit', 'KILOGRAMS')
if isinstance(unit_str, str):
try:
unit = MeasurementUnit[unit_str.upper()]
except KeyError:
# Try without 'S' for singular forms
try:
unit = MeasurementUnit[unit_str.upper().rstrip('S')]
except KeyError:
unit = MeasurementUnit.KILOGRAMS
else:
unit = unit_str
new_recipe_ingredient = RecipeIngredient(
id=str(transformed_id),
tenant_id=virtual_uuid,
recipe_id=recipe_id,
ingredient_id=recipe_ingredient_data['ingredient_id'],
quantity=recipe_ingredient_data['quantity'],
unit=unit,
unit_cost=recipe_ingredient_data.get('cost_per_unit') or recipe_ingredient_data.get('unit_cost', 0.0),
total_cost=recipe_ingredient_data.get('total_cost'),
ingredient_order=recipe_ingredient_data.get('sequence') or recipe_ingredient_data.get('ingredient_order', 1),
is_optional=recipe_ingredient_data.get('is_optional', False),
ingredient_notes=recipe_ingredient_data.get('notes') or recipe_ingredient_data.get('ingredient_notes')
)
db.add(new_recipe_ingredient)
stats["recipe_ingredients"] += 1
await db.commit()
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info(
"Recipes data cloned successfully",
virtual_tenant_id=virtual_tenant_id,
records_cloned=stats,
duration_ms=duration_ms
)
return {
"service": "recipes",
"status": "completed",
"records_cloned": sum(stats.values()),
"duration_ms": duration_ms,
"details": {
"recipes": stats["recipes"],
"recipe_ingredients": stats["recipe_ingredients"],
"virtual_tenant_id": str(virtual_tenant_id)
}
}
except ValueError as e:
logger.error("Invalid UUID format", error=str(e), virtual_tenant_id=virtual_tenant_id)
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
except Exception as e:
logger.error(
"Failed to clone recipes data",
error=str(e),
virtual_tenant_id=virtual_tenant_id,
exc_info=True
)
# Rollback on error
await db.rollback()
return {
"service": "recipes",
"status": "failed",
"records_cloned": 0,
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
"error": str(e)
}
@router.get("/clone/health")
async def clone_health_check():
"""
Health check for internal cloning endpoint
Used by orchestrator to verify service availability
"""
return {
"service": "recipes",
"clone_endpoint": "available",
"version": "2.0.0"
}
@router.delete("/tenant/{virtual_tenant_id}")
async def delete_demo_tenant_data(
virtual_tenant_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""
Delete all demo data for a virtual tenant.
This endpoint is idempotent - safe to call multiple times.
"""
start_time = datetime.now(timezone.utc)
records_deleted = {
"recipes": 0,
"recipe_ingredients": 0,
"total": 0
}
try:
# Delete in reverse dependency order
# 1. Delete recipe ingredients (depends on recipes)
result = await db.execute(
delete(RecipeIngredient)
.where(RecipeIngredient.tenant_id == virtual_tenant_id)
)
records_deleted["recipe_ingredients"] = result.rowcount
# 2. Delete recipes
result = await db.execute(
delete(Recipe)
.where(Recipe.tenant_id == virtual_tenant_id)
)
records_deleted["recipes"] = result.rowcount
records_deleted["total"] = sum(records_deleted.values())
await db.commit()
logger.info(
"demo_data_deleted",
service="recipes",
virtual_tenant_id=str(virtual_tenant_id),
records_deleted=records_deleted
)
return {
"service": "recipes",
"status": "deleted",
"virtual_tenant_id": str(virtual_tenant_id),
"records_deleted": records_deleted,
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
}
except Exception as e:
await db.rollback()
logger.error(
"demo_data_deletion_failed",
service="recipes",
virtual_tenant_id=str(virtual_tenant_id),
error=str(e)
)
raise HTTPException(
status_code=500,
detail=f"Failed to delete demo data: {str(e)}"
)

View File

@@ -0,0 +1,306 @@
# 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)}")

View File

@@ -0,0 +1,166 @@
# services/recipes/app/api/recipe_quality_configs.py
"""
Recipe Quality Configuration API - Atomic CRUD operations on RecipeQualityConfiguration
"""
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
from uuid import UUID
import logging
from ..core.database import get_db
from ..services.recipe_service import RecipeService
from ..schemas.recipes import (
RecipeQualityConfiguration,
RecipeQualityConfigurationUpdate
)
from shared.routing import RouteBuilder, RouteCategory
from shared.auth.access_control import require_user_role
route_builder = RouteBuilder('recipes')
logger = logging.getLogger(__name__)
router = APIRouter(tags=["recipe-quality-configs"])
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.get(
route_builder.build_custom_route(RouteCategory.BASE, ["{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)
recipe = await recipe_service.get_recipe(tenant_id, recipe_id)
if not recipe:
raise HTTPException(status_code=404, detail="Recipe not found")
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(
route_builder.build_custom_route(RouteCategory.BASE, ["{recipe_id}", "quality-configuration"]),
response_model=RecipeQualityConfiguration
)
@require_user_role(['admin', 'owner', 'member'])
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)
recipe = await recipe_service.get_recipe(tenant_id, recipe_id)
if not recipe:
raise HTTPException(status_code=404, detail="Recipe not found")
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(
route_builder.build_custom_route(RouteCategory.BASE, ["{recipe_id}", "quality-configuration", "stages", "{stage}", "templates"])
)
@require_user_role(['admin', 'owner', 'member'])
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)
recipe = await recipe_service.get_recipe(tenant_id, recipe_id)
if not recipe:
raise HTTPException(status_code=404, detail="Recipe not found")
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(
route_builder.build_custom_route(RouteCategory.BASE, ["{recipe_id}", "quality-configuration", "stages", "{stage}", "templates", "{template_id}"])
)
@require_user_role(['admin', 'owner'])
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)
recipe = await recipe_service.get_recipe(tenant_id, recipe_id)
if not recipe:
raise HTTPException(status_code=404, detail="Recipe not found")
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")

View File

@@ -0,0 +1,504 @@
# 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
import httpx
from ..core.database import get_db
from ..services.recipe_service import RecipeService
from ..schemas.recipes import (
RecipeCreate,
RecipeUpdate,
RecipeResponse,
)
from ..models import AuditLog
from shared.routing import RouteBuilder, RouteCategory
from shared.auth.access_control import require_user_role, service_only_access
from shared.auth.decorators import get_current_user_dep
from shared.security import create_audit_logger, AuditSeverity, AuditAction
from shared.services.tenant_deletion import TenantDataDeletionResult
route_builder = RouteBuilder('recipes')
logger = logging.getLogger(__name__)
audit_logger = create_audit_logger("recipes-service", AuditLog)
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:
# CRITICAL: Check subscription limit before creating
from ..core.config import settings
async with httpx.AsyncClient(timeout=5.0) as client:
try:
# Check recipe limit (not product limit)
limit_check_response = await client.get(
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/recipes/can-add",
headers={
"x-user-id": str(current_user.get('user_id')),
"x-tenant-id": str(tenant_id)
}
)
if limit_check_response.status_code == 200:
limit_check = limit_check_response.json()
if not limit_check.get('can_add', False):
logger.warning(
f"Recipe limit exceeded for tenant {tenant_id}: {limit_check.get('reason')}"
)
raise HTTPException(
status_code=402,
detail={
"error": "recipe_limit_exceeded",
"message": limit_check.get('reason', 'Recipe limit exceeded'),
"current_count": limit_check.get('current_count'),
"max_allowed": limit_check.get('max_allowed'),
"upgrade_required": True
}
)
else:
logger.warning(
f"Failed to check recipe limit for tenant {tenant_id}, allowing creation"
)
except httpx.TimeoutException:
logger.warning(f"Timeout checking recipe limit for tenant {tenant_id}, allowing creation")
except httpx.RequestError as e:
logger.warning(f"Error checking recipe limit for tenant {tenant_id}: {e}, allowing creation")
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"])
logger.info(f"Recipe created successfully for tenant {tenant_id}: {result['data'].get('name')}")
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")
# ===== Tenant Data Deletion Endpoints =====
@router.delete("/tenant/{tenant_id}")
@service_only_access
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")
@service_only_access
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)}"
)