307 lines
10 KiB
Python
307 lines
10 KiB
Python
# 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)}")
|