# services/inventory/app/api/ingredients.py """ Base CRUD operations for inventory ingredients resources Following standardized URL structure: /api/v1/tenants/{tenant_id}/inventory/{resource} """ from typing import List, Optional from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Path, status from sqlalchemy.ext.asyncio import AsyncSession import httpx import structlog from app.core.database import get_db from app.services.inventory_service import InventoryService from app.models import AuditLog from app.schemas.inventory import ( IngredientCreate, IngredientUpdate, IngredientResponse, StockResponse, StockCreate, StockUpdate, BulkIngredientCreate, BulkIngredientResponse, BulkIngredientResult, ) from shared.auth.decorators import get_current_user_dep from shared.auth.access_control import require_user_role, admin_role_required, owner_role_required from shared.routing import RouteBuilder from shared.security import create_audit_logger, AuditSeverity, AuditAction logger = structlog.get_logger() # Create route builder for consistent URL structure route_builder = RouteBuilder('inventory') router = APIRouter(tags=["ingredients"]) # Initialize audit logger audit_logger = create_audit_logger("inventory-service", AuditLog) # Helper function to extract user ID from user object def get_current_user_id(current_user: dict = Depends(get_current_user_dep)) -> UUID: """Extract user ID from current user context""" user_id = current_user.get('user_id') if not user_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User ID not found in context" ) return UUID(user_id) # ===== INGREDIENTS ENDPOINTS ===== @router.post( route_builder.build_base_route("ingredients"), response_model=IngredientResponse, status_code=status.HTTP_201_CREATED ) @require_user_role(['admin', 'owner']) async def create_ingredient( ingredient_data: IngredientCreate, tenant_id: UUID = Path(..., description="Tenant ID"), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Create a new ingredient (Admin/Manager only)""" try: # CRITICAL: Check subscription limit before creating from app.core.config import settings async with httpx.AsyncClient(timeout=5.0) as client: try: limit_check_response = await client.get( f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/subscription/limits/products", 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( "Product limit exceeded", tenant_id=str(tenant_id), current=limit_check.get('current_count'), max=limit_check.get('max_allowed'), reason=limit_check.get('reason') ) raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail={ "error": "product_limit_exceeded", "message": limit_check.get('reason', 'Product limit exceeded'), "current_count": limit_check.get('current_count'), "max_allowed": limit_check.get('max_allowed'), "upgrade_required": True } ) else: logger.warning( "Failed to check product limit, allowing creation", tenant_id=str(tenant_id), status_code=limit_check_response.status_code ) except httpx.TimeoutException: logger.warning( "Timeout checking product limit, allowing creation", tenant_id=str(tenant_id) ) except httpx.RequestError as e: logger.warning( "Error checking product limit, allowing creation", tenant_id=str(tenant_id), error=str(e) ) # Extract user ID - handle service tokens raw_user_id = current_user.get('user_id') if current_user.get('type') == 'service': user_id = None else: try: user_id = UUID(raw_user_id) except (ValueError, TypeError): user_id = None service = InventoryService() ingredient = await service.create_ingredient(ingredient_data, tenant_id, user_id) logger.info( "Ingredient created successfully", tenant_id=str(tenant_id), ingredient_id=str(ingredient.id), ingredient_name=ingredient.name ) return ingredient except HTTPException: raise except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e) ) except Exception as e: logger.error( "Failed to create ingredient", tenant_id=str(tenant_id), error=str(e) ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create ingredient" ) @router.post( route_builder.build_base_route("ingredients/bulk"), response_model=BulkIngredientResponse, status_code=status.HTTP_201_CREATED ) @require_user_role(['admin', 'owner']) async def bulk_create_ingredients( bulk_data: BulkIngredientCreate, tenant_id: UUID = Path(..., description="Tenant ID"), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Create multiple ingredients in a single transaction (Admin/Manager only)""" import uuid transaction_id = str(uuid.uuid4()) try: # CRITICAL: Check subscription limit ONCE before creating any ingredients from app.core.config import settings total_requested = len(bulk_data.ingredients) async with httpx.AsyncClient(timeout=5.0) as client: try: # Check if we can add this many products limit_check_response = await client.get( f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/subscription/limits/products", 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( "Bulk product limit exceeded", tenant_id=str(tenant_id), requested=total_requested, current=limit_check.get('current_count'), max=limit_check.get('max_allowed'), reason=limit_check.get('reason') ) raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail={ "error": "product_limit_exceeded", "message": limit_check.get('reason', 'Product limit exceeded'), "requested": total_requested, "current_count": limit_check.get('current_count'), "max_allowed": limit_check.get('max_allowed'), "upgrade_required": True } ) else: logger.warning( "Failed to check product limit, allowing bulk creation", tenant_id=str(tenant_id), status_code=limit_check_response.status_code ) except httpx.TimeoutException: logger.warning( "Timeout checking product limit, allowing bulk creation", tenant_id=str(tenant_id) ) except httpx.RequestError as e: logger.warning( "Error checking product limit, allowing bulk creation", tenant_id=str(tenant_id), error=str(e) ) # Extract user ID - handle service tokens raw_user_id = current_user.get('user_id') if current_user.get('type') == 'service': user_id = None else: try: user_id = UUID(raw_user_id) except (ValueError, TypeError): user_id = None # Create all ingredients service = InventoryService() results: List[BulkIngredientResult] = [] total_created = 0 total_failed = 0 for index, ingredient_data in enumerate(bulk_data.ingredients): try: ingredient = await service.create_ingredient(ingredient_data, tenant_id, user_id) results.append(BulkIngredientResult( index=index, success=True, ingredient=IngredientResponse.from_orm(ingredient), error=None )) total_created += 1 logger.debug( "Ingredient created in bulk operation", tenant_id=str(tenant_id), ingredient_id=str(ingredient.id), ingredient_name=ingredient.name, index=index, transaction_id=transaction_id ) except Exception as e: results.append(BulkIngredientResult( index=index, success=False, ingredient=None, error=str(e) )) total_failed += 1 logger.warning( "Failed to create ingredient in bulk operation", tenant_id=str(tenant_id), index=index, error=str(e), transaction_id=transaction_id ) logger.info( "Bulk ingredient creation completed", tenant_id=str(tenant_id), total_requested=total_requested, total_created=total_created, total_failed=total_failed, transaction_id=transaction_id ) return BulkIngredientResponse( total_requested=total_requested, total_created=total_created, total_failed=total_failed, results=results, transaction_id=transaction_id ) except HTTPException: raise except Exception as e: logger.error( "Failed to process bulk ingredient creation", tenant_id=str(tenant_id), error=str(e), transaction_id=transaction_id ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to process bulk ingredient creation" ) @router.get( route_builder.build_base_route("ingredients/count"), response_model=dict ) async def count_ingredients( tenant_id: UUID = Path(...), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Get count of ingredients for a tenant (All users)""" try: service = InventoryService() count = await service.count_ingredients_by_tenant(tenant_id) return { "tenant_id": str(tenant_id), "ingredient_count": count } except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to count ingredients: {str(e)}" ) @router.get( route_builder.build_resource_detail_route("ingredients", "ingredient_id"), response_model=IngredientResponse ) async def get_ingredient( ingredient_id: UUID, tenant_id: UUID = Path(..., description="Tenant ID"), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Get ingredient by ID (All users)""" try: service = InventoryService() ingredient = await service.get_ingredient(ingredient_id, tenant_id) if not ingredient: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Ingredient not found" ) return ingredient except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get ingredient" ) @router.put( route_builder.build_resource_detail_route("ingredients", "ingredient_id"), response_model=IngredientResponse ) @require_user_role(['admin', 'owner', 'member']) async def update_ingredient( ingredient_id: UUID, ingredient_data: IngredientUpdate, tenant_id: UUID = Path(..., description="Tenant ID"), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Update ingredient (Admin/Manager/User)""" try: service = InventoryService() ingredient = await service.update_ingredient(ingredient_id, ingredient_data, tenant_id) if not ingredient: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Ingredient not found" ) return ingredient except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e) ) except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update ingredient" ) @router.get( route_builder.build_base_route("ingredients"), response_model=List[IngredientResponse] ) async def list_ingredients( tenant_id: UUID = Path(..., description="Tenant ID"), skip: int = Query(0, ge=0, description="Number of records to skip"), limit: int = Query(100, ge=1, le=1000, description="Number of records to return"), category: Optional[str] = Query(None, description="Filter by category"), product_type: Optional[str] = Query(None, description="Filter by product type"), is_active: Optional[bool] = Query(None, description="Filter by active status"), is_low_stock: Optional[bool] = Query(None, description="Filter by low stock status"), needs_reorder: Optional[bool] = Query(None, description="Filter by reorder needed"), search: Optional[str] = Query(None, description="Search in name, SKU, or barcode"), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """List ingredients with filtering (All users)""" try: service = InventoryService() # Build filters filters = {} if category: filters['category'] = category if product_type: filters['product_type'] = product_type if is_active is not None: filters['is_active'] = is_active if is_low_stock is not None: filters['is_low_stock'] = is_low_stock if needs_reorder is not None: filters['needs_reorder'] = needs_reorder if search: filters['search'] = search ingredients = await service.get_ingredients(tenant_id, skip, limit, filters) return ingredients except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to list ingredients" ) @router.delete( route_builder.build_resource_detail_route("ingredients", "ingredient_id"), status_code=status.HTTP_204_NO_CONTENT ) @admin_role_required async def soft_delete_ingredient( ingredient_id: UUID, tenant_id: UUID = Path(..., description="Tenant ID"), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Soft delete ingredient - mark as inactive (Admin only)""" try: service = InventoryService() result = await service.soft_delete_ingredient(ingredient_id, tenant_id) return None except ValueError as e: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=str(e) ) except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to soft delete ingredient" ) @router.delete( route_builder.build_nested_resource_route("ingredients", "ingredient_id", "hard"), response_model=dict ) @admin_role_required async def hard_delete_ingredient( ingredient_id: UUID, tenant_id: UUID = Path(..., description="Tenant ID"), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Hard delete ingredient and all associated data (Admin only)""" try: service = InventoryService() deletion_summary = await service.hard_delete_ingredient(ingredient_id, tenant_id) # Log audit event for hard deletion try: await audit_logger.log_deletion( db_session=db, tenant_id=str(tenant_id), user_id=current_user["user_id"], resource_type="ingredient", resource_id=str(ingredient_id), resource_data=deletion_summary, description=f"Hard deleted ingredient and all associated data", endpoint=f"/ingredients/{ingredient_id}/hard", method="DELETE" ) except Exception as audit_error: import structlog logger = structlog.get_logger() logger.warning("Failed to log audit event", error=str(audit_error)) return deletion_summary except ValueError as e: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=str(e) ) except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to hard delete ingredient" ) @router.get( route_builder.build_nested_resource_route("ingredients", "ingredient_id", "stock"), response_model=List[StockResponse] ) async def get_ingredient_stock( ingredient_id: UUID, tenant_id: UUID = Path(..., description="Tenant ID"), include_unavailable: bool = Query(False, description="Include unavailable stock"), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Get stock entries for an ingredient (All users)""" try: service = InventoryService() stock_entries = await service.get_stock_by_ingredient( ingredient_id, tenant_id, include_unavailable ) return stock_entries except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get ingredient stock" )