Files
bakery-ia/services/inventory/app/api/ingredients.py
2026-01-16 15:19:34 +01:00

557 lines
20 KiB
Python

# 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"
)