New alert service
This commit is contained in:
149
services/inventory/app/api/batch.py
Normal file
149
services/inventory/app/api/batch.py
Normal file
@@ -0,0 +1,149 @@
|
||||
# services/inventory/app/api/batch.py
|
||||
"""
|
||||
Inventory Batch API - Batch operations for enterprise dashboards
|
||||
|
||||
Phase 2 optimization: Eliminate N+1 query patterns by fetching inventory data
|
||||
for multiple tenants in a single request.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||
from typing import List, Dict, Any
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
import asyncio
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.dashboard_service import DashboardService
|
||||
from app.services.inventory_service import InventoryService
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
|
||||
router = APIRouter(tags=["inventory-batch"])
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class InventorySummaryBatchRequest(BaseModel):
|
||||
"""Request model for batch inventory summary"""
|
||||
tenant_ids: List[str] = Field(..., description="List of tenant IDs", max_length=100)
|
||||
|
||||
|
||||
class InventorySummary(BaseModel):
|
||||
"""Inventory summary for a single tenant"""
|
||||
tenant_id: str
|
||||
total_value: float
|
||||
out_of_stock_count: int
|
||||
low_stock_count: int
|
||||
adequate_stock_count: int
|
||||
total_ingredients: int
|
||||
|
||||
|
||||
@router.post("/batch/inventory-summary", response_model=Dict[str, InventorySummary])
|
||||
async def get_inventory_summary_batch(
|
||||
request: InventorySummaryBatchRequest = Body(...),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get inventory summary for multiple tenants in a single request.
|
||||
|
||||
Optimized for enterprise dashboards to eliminate N+1 query patterns.
|
||||
Fetches inventory data for all tenants in parallel.
|
||||
|
||||
Args:
|
||||
request: Batch request with tenant IDs
|
||||
|
||||
Returns:
|
||||
Dictionary mapping tenant_id -> inventory summary
|
||||
|
||||
Example:
|
||||
POST /api/v1/inventory/batch/inventory-summary
|
||||
{
|
||||
"tenant_ids": ["tenant-1", "tenant-2", "tenant-3"]
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"tenant-1": {"tenant_id": "tenant-1", "total_value": 15000, ...},
|
||||
"tenant-2": {"tenant_id": "tenant-2", "total_value": 12000, ...},
|
||||
"tenant-3": {"tenant_id": "tenant-3", "total_value": 18000, ...}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if len(request.tenant_ids) > 100:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Maximum 100 tenant IDs allowed per batch request"
|
||||
)
|
||||
|
||||
if not request.tenant_ids:
|
||||
return {}
|
||||
|
||||
logger.info(
|
||||
"Batch fetching inventory summaries",
|
||||
tenant_count=len(request.tenant_ids)
|
||||
)
|
||||
|
||||
async def fetch_tenant_inventory(tenant_id: str) -> tuple[str, InventorySummary]:
|
||||
"""Fetch inventory summary for a single tenant"""
|
||||
try:
|
||||
tenant_uuid = UUID(tenant_id)
|
||||
dashboard_service = DashboardService(
|
||||
inventory_service=InventoryService(),
|
||||
food_safety_service=None
|
||||
)
|
||||
|
||||
overview = await dashboard_service.get_inventory_overview(db, tenant_uuid)
|
||||
|
||||
return tenant_id, InventorySummary(
|
||||
tenant_id=tenant_id,
|
||||
total_value=float(overview.get('total_value', 0)),
|
||||
out_of_stock_count=int(overview.get('out_of_stock_count', 0)),
|
||||
low_stock_count=int(overview.get('low_stock_count', 0)),
|
||||
adequate_stock_count=int(overview.get('adequate_stock_count', 0)),
|
||||
total_ingredients=int(overview.get('total_ingredients', 0))
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to fetch inventory for tenant in batch",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e)
|
||||
)
|
||||
return tenant_id, InventorySummary(
|
||||
tenant_id=tenant_id,
|
||||
total_value=0.0,
|
||||
out_of_stock_count=0,
|
||||
low_stock_count=0,
|
||||
adequate_stock_count=0,
|
||||
total_ingredients=0
|
||||
)
|
||||
|
||||
# Fetch all tenant inventory data in parallel
|
||||
tasks = [fetch_tenant_inventory(tid) for tid in request.tenant_ids]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Build result dictionary
|
||||
result_dict = {}
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
logger.error("Exception in batch inventory fetch", error=str(result))
|
||||
continue
|
||||
tenant_id, summary = result
|
||||
result_dict[tenant_id] = summary
|
||||
|
||||
logger.info(
|
||||
"Batch inventory summaries retrieved",
|
||||
requested_count=len(request.tenant_ids),
|
||||
successful_count=len(result_dict)
|
||||
)
|
||||
|
||||
return result_dict
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error in batch inventory summary", error=str(e), exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to fetch batch inventory summaries: {str(e)}"
|
||||
)
|
||||
@@ -30,6 +30,7 @@ from app.schemas.dashboard import (
|
||||
AlertSummary,
|
||||
RecentActivity
|
||||
)
|
||||
from app.utils.cache import get_cached, set_cached, make_cache_key
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
@@ -62,19 +63,34 @@ async def get_inventory_dashboard_summary(
|
||||
dashboard_service: DashboardService = Depends(get_dashboard_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get comprehensive inventory dashboard summary"""
|
||||
"""Get comprehensive inventory dashboard summary with caching (30s TTL)"""
|
||||
try:
|
||||
# PHASE 2: Check cache first (only if no filters applied)
|
||||
cache_key = None
|
||||
if filters is None:
|
||||
cache_key = make_cache_key("inventory_dashboard", str(tenant_id))
|
||||
cached_result = await get_cached(cache_key)
|
||||
if cached_result is not None:
|
||||
logger.debug("Cache hit for inventory dashboard", cache_key=cache_key, tenant_id=str(tenant_id))
|
||||
return InventoryDashboardSummary(**cached_result)
|
||||
|
||||
# Cache miss or filters applied - fetch from database
|
||||
summary = await dashboard_service.get_inventory_dashboard_summary(db, tenant_id, filters)
|
||||
|
||||
logger.info("Dashboard summary retrieved",
|
||||
|
||||
# PHASE 2: Cache the result (30s TTL for inventory levels)
|
||||
if cache_key:
|
||||
await set_cached(cache_key, summary.model_dump(), ttl=30)
|
||||
logger.debug("Cached inventory dashboard", cache_key=cache_key, ttl=30, tenant_id=str(tenant_id))
|
||||
|
||||
logger.info("Dashboard summary retrieved",
|
||||
tenant_id=str(tenant_id),
|
||||
total_ingredients=summary.total_ingredients)
|
||||
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting dashboard summary",
|
||||
tenant_id=str(tenant_id),
|
||||
logger.error("Error getting dashboard summary",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -82,6 +98,41 @@ async def get_inventory_dashboard_summary(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_dashboard_route("overview")
|
||||
)
|
||||
async def get_inventory_dashboard_overview(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
dashboard_service: DashboardService = Depends(get_dashboard_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get lightweight inventory dashboard overview for health checks.
|
||||
|
||||
This endpoint is optimized for frequent polling by the orchestrator service
|
||||
for dashboard health-status checks. It returns only essential metrics needed
|
||||
to determine inventory health status.
|
||||
"""
|
||||
try:
|
||||
overview = await dashboard_service.get_inventory_overview(db, tenant_id)
|
||||
|
||||
logger.info("Inventory dashboard overview retrieved",
|
||||
tenant_id=str(tenant_id),
|
||||
out_of_stock_count=overview.get('out_of_stock_count', 0))
|
||||
|
||||
return overview
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting inventory dashboard overview",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve inventory dashboard overview"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_dashboard_route("food-safety"),
|
||||
response_model=FoodSafetyDashboard
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import Dict, Any
|
||||
from decimal import Decimal
|
||||
import structlog
|
||||
|
||||
from shared.messaging.rabbitmq import RabbitMQClient
|
||||
from shared.messaging import RabbitMQClient
|
||||
from app.core.database import database_manager
|
||||
from app.repositories.stock_repository import StockRepository
|
||||
from app.repositories.stock_movement_repository import StockMovementRepository
|
||||
@@ -137,6 +137,23 @@ class DeliveryEventConsumer:
|
||||
|
||||
# Create a new stock batch entry for this delivery
|
||||
# The Stock model uses batch tracking - each delivery creates a new batch entry
|
||||
# Extract unit cost from delivery item
|
||||
unit_cost = Decimal('0')
|
||||
try:
|
||||
if 'unit_cost' in item:
|
||||
unit_cost = Decimal(str(item['unit_cost']))
|
||||
elif 'unit_price' in item:
|
||||
unit_cost = Decimal(str(item['unit_price']))
|
||||
elif 'price' in item:
|
||||
unit_cost = Decimal(str(item['price']))
|
||||
except (ValueError, TypeError, KeyError) as e:
|
||||
logger.warning("Could not extract unit cost from delivery item for stock entry",
|
||||
item_id=item.get('id'),
|
||||
error=str(e))
|
||||
|
||||
# Calculate total cost
|
||||
total_cost = unit_cost * accepted_quantity
|
||||
|
||||
stock_data = {
|
||||
'tenant_id': tenant_id,
|
||||
'ingredient_id': ingredient_id,
|
||||
@@ -153,9 +170,9 @@ class DeliveryEventConsumer:
|
||||
'received_date': datetime.fromisoformat(received_at.replace('Z', '+00:00')) if received_at else datetime.now(timezone.utc),
|
||||
'expiration_date': datetime.fromisoformat(item.get('expiry_date').replace('Z', '+00:00')) if item.get('expiry_date') else None,
|
||||
|
||||
# Cost (TODO: Get actual unit cost from delivery item if available)
|
||||
'unit_cost': Decimal('0'),
|
||||
'total_cost': Decimal('0'),
|
||||
# Cost - extracted from delivery item
|
||||
'unit_cost': unit_cost,
|
||||
'total_cost': total_cost,
|
||||
|
||||
# Production stage - default to raw ingredient for deliveries
|
||||
'production_stage': 'raw_ingredient',
|
||||
@@ -182,12 +199,26 @@ class DeliveryEventConsumer:
|
||||
from app.models.inventory import StockMovementType
|
||||
from app.schemas.inventory import StockMovementCreate
|
||||
|
||||
# Extract unit cost from delivery item or default to 0
|
||||
unit_cost = Decimal('0')
|
||||
try:
|
||||
if 'unit_cost' in item:
|
||||
unit_cost = Decimal(str(item['unit_cost']))
|
||||
elif 'unit_price' in item:
|
||||
unit_cost = Decimal(str(item['unit_price']))
|
||||
elif 'price' in item:
|
||||
unit_cost = Decimal(str(item['price']))
|
||||
except (ValueError, TypeError, KeyError) as e:
|
||||
logger.warning("Could not extract unit cost from delivery item",
|
||||
item_id=item.get('id'),
|
||||
error=str(e))
|
||||
|
||||
movement_data = StockMovementCreate(
|
||||
ingredient_id=ingredient_id,
|
||||
stock_id=stock.id,
|
||||
movement_type=StockMovementType.PURCHASE,
|
||||
quantity=float(accepted_quantity),
|
||||
unit_cost=Decimal('0'), # TODO: Get from delivery item
|
||||
unit_cost=unit_cost,
|
||||
reference_number=f"DEL-{delivery_id}",
|
||||
reason_code='delivery',
|
||||
notes=f"Delivery received from PO {po_id}. Batch: {item.get('batch_lot_number', 'N/A')}",
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Dict, Any
|
||||
import json
|
||||
|
||||
from app.services.internal_transfer_service import InternalTransferInventoryService
|
||||
from shared.messaging.rabbitmq import RabbitMQClient
|
||||
from shared.messaging import RabbitMQClient
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
@@ -13,9 +13,11 @@ from app.core.database import database_manager
|
||||
from app.services.inventory_alert_service import InventoryAlertService
|
||||
from app.consumers.delivery_event_consumer import DeliveryEventConsumer
|
||||
from shared.service_base import StandardFastAPIService
|
||||
from shared.messaging import UnifiedEventPublisher
|
||||
import asyncio
|
||||
|
||||
from app.api import (
|
||||
batch,
|
||||
ingredients,
|
||||
stock_entries,
|
||||
transformations,
|
||||
@@ -79,10 +81,12 @@ class InventoryService(StandardFastAPIService):
|
||||
|
||||
async def _setup_messaging(self):
|
||||
"""Setup messaging for inventory service"""
|
||||
from shared.messaging.rabbitmq import RabbitMQClient
|
||||
from shared.messaging import RabbitMQClient
|
||||
try:
|
||||
self.rabbitmq_client = RabbitMQClient(settings.RABBITMQ_URL, service_name="inventory-service")
|
||||
await self.rabbitmq_client.connect()
|
||||
# Create event publisher
|
||||
self.event_publisher = UnifiedEventPublisher(self.rabbitmq_client, "inventory-service")
|
||||
self.logger.info("Inventory service messaging setup completed")
|
||||
except Exception as e:
|
||||
self.logger.error("Failed to setup inventory messaging", error=str(e))
|
||||
@@ -105,13 +109,16 @@ class InventoryService(StandardFastAPIService):
|
||||
# Call parent startup (includes database, messaging, etc.)
|
||||
await super().on_startup(app)
|
||||
|
||||
# Initialize alert service
|
||||
alert_service = InventoryAlertService(settings)
|
||||
await alert_service.start()
|
||||
self.logger.info("Inventory alert service started")
|
||||
# Initialize alert service with EventPublisher
|
||||
if self.event_publisher:
|
||||
alert_service = InventoryAlertService(self.event_publisher)
|
||||
await alert_service.start()
|
||||
self.logger.info("Inventory alert service started")
|
||||
|
||||
# Store alert service in app state
|
||||
app.state.alert_service = alert_service
|
||||
# Store alert service in app state
|
||||
app.state.alert_service = alert_service
|
||||
else:
|
||||
self.logger.error("Event publisher not initialized, alert service unavailable")
|
||||
|
||||
# Initialize and start delivery event consumer
|
||||
self.delivery_consumer = DeliveryEventConsumer()
|
||||
@@ -179,6 +186,7 @@ service.setup_standard_endpoints()
|
||||
# Include new standardized routers
|
||||
# IMPORTANT: Register audit router FIRST to avoid route matching conflicts
|
||||
service.add_router(audit.router)
|
||||
service.add_router(batch.router)
|
||||
service.add_router(ingredients.router)
|
||||
service.add_router(stock_entries.router)
|
||||
service.add_router(transformations.router)
|
||||
|
||||
@@ -436,7 +436,33 @@ class DashboardService:
|
||||
except Exception as e:
|
||||
logger.error("Failed to get live metrics", error=str(e))
|
||||
raise
|
||||
|
||||
|
||||
async def get_inventory_overview(
|
||||
self,
|
||||
db,
|
||||
tenant_id: UUID
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get lightweight inventory overview for orchestrator health checks.
|
||||
Returns minimal data needed by dashboard health-status endpoint.
|
||||
This is a fast endpoint optimized for frequent polling.
|
||||
"""
|
||||
try:
|
||||
# Get only the essential metric needed by orchestrator
|
||||
inventory_summary = await self.inventory_service.get_inventory_summary(tenant_id)
|
||||
|
||||
return {
|
||||
"out_of_stock_count": inventory_summary.out_of_stock_items,
|
||||
"tenant_id": str(tenant_id),
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get inventory overview",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
async def export_dashboard_data(
|
||||
self,
|
||||
db,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,38 +1,33 @@
|
||||
"""
|
||||
Inventory Notification Service
|
||||
Inventory Notification Service - Simplified
|
||||
|
||||
Emits informational notifications for inventory state changes:
|
||||
- stock_received: When deliveries arrive
|
||||
- stock_movement: Transfers, adjustments
|
||||
- stock_updated: General stock updates
|
||||
Emits minimal events using EventPublisher.
|
||||
All enrichment handled by alert_processor.
|
||||
|
||||
These are NOTIFICATIONS (not alerts) - informational state changes that don't require user action.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from uuid import UUID
|
||||
import structlog
|
||||
|
||||
from shared.schemas.event_classification import RawEvent, EventClass, EventDomain
|
||||
from shared.alerts.base_service import BaseAlertService
|
||||
from shared.messaging import UnifiedEventPublisher
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InventoryNotificationService(BaseAlertService):
|
||||
class InventoryNotificationService:
|
||||
"""
|
||||
Service for emitting inventory notifications (informational state changes).
|
||||
Service for emitting inventory notifications using EventPublisher.
|
||||
"""
|
||||
|
||||
def __init__(self, rabbitmq_url: str = None):
|
||||
super().__init__(service_name="inventory", rabbitmq_url=rabbitmq_url)
|
||||
def __init__(self, event_publisher: UnifiedEventPublisher):
|
||||
self.publisher = event_publisher
|
||||
|
||||
async def emit_stock_received_notification(
|
||||
self,
|
||||
db: Session,
|
||||
tenant_id: str,
|
||||
tenant_id: UUID,
|
||||
stock_receipt_id: str,
|
||||
ingredient_id: str,
|
||||
ingredient_name: str,
|
||||
@@ -43,61 +38,38 @@ class InventoryNotificationService(BaseAlertService):
|
||||
) -> None:
|
||||
"""
|
||||
Emit notification when stock is received.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tenant_id: Tenant ID
|
||||
stock_receipt_id: Stock receipt ID
|
||||
ingredient_id: Ingredient ID
|
||||
ingredient_name: Ingredient name
|
||||
quantity_received: Quantity received
|
||||
unit: Unit of measurement
|
||||
supplier_name: Supplier name (optional)
|
||||
delivery_id: Delivery ID (optional)
|
||||
"""
|
||||
try:
|
||||
# Create notification event
|
||||
event = RawEvent(
|
||||
tenant_id=tenant_id,
|
||||
event_class=EventClass.NOTIFICATION,
|
||||
event_domain=EventDomain.INVENTORY,
|
||||
event_type="stock_received",
|
||||
title=f"Stock Received: {ingredient_name}",
|
||||
message=f"Received {quantity_received} {unit} of {ingredient_name}"
|
||||
+ (f" from {supplier_name}" if supplier_name else ""),
|
||||
service="inventory",
|
||||
event_metadata={
|
||||
"stock_receipt_id": stock_receipt_id,
|
||||
"ingredient_id": ingredient_id,
|
||||
"ingredient_name": ingredient_name,
|
||||
"quantity_received": quantity_received,
|
||||
"unit": unit,
|
||||
"supplier_name": supplier_name,
|
||||
"delivery_id": delivery_id,
|
||||
"received_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
message = f"Received {quantity_received} {unit} of {ingredient_name}"
|
||||
if supplier_name:
|
||||
message += f" from {supplier_name}"
|
||||
|
||||
# Publish to RabbitMQ for processing
|
||||
await self.publish_item(tenant_id, event.dict(), item_type="notification")
|
||||
metadata = {
|
||||
"stock_receipt_id": stock_receipt_id,
|
||||
"ingredient_id": ingredient_id,
|
||||
"ingredient_name": ingredient_name,
|
||||
"quantity_received": float(quantity_received),
|
||||
"unit": unit,
|
||||
"supplier_name": supplier_name,
|
||||
"delivery_id": delivery_id,
|
||||
"received_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Stock received notification emitted: {ingredient_name} ({quantity_received} {unit})",
|
||||
extra={"tenant_id": tenant_id, "ingredient_id": ingredient_id}
|
||||
)
|
||||
await self.publisher.publish_notification(
|
||||
event_type="inventory.stock_received",
|
||||
tenant_id=tenant_id,
|
||||
data=metadata
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to emit stock received notification: {e}",
|
||||
extra={"tenant_id": tenant_id, "ingredient_id": ingredient_id},
|
||||
exc_info=True,
|
||||
)
|
||||
logger.info(
|
||||
"stock_received_notification_emitted",
|
||||
tenant_id=str(tenant_id),
|
||||
ingredient_name=ingredient_name,
|
||||
quantity_received=quantity_received
|
||||
)
|
||||
|
||||
async def emit_stock_movement_notification(
|
||||
self,
|
||||
db: Session,
|
||||
tenant_id: str,
|
||||
tenant_id: UUID,
|
||||
movement_id: str,
|
||||
ingredient_id: str,
|
||||
ingredient_name: str,
|
||||
@@ -110,82 +82,54 @@ class InventoryNotificationService(BaseAlertService):
|
||||
) -> None:
|
||||
"""
|
||||
Emit notification for stock movements (transfers, adjustments, waste).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tenant_id: Tenant ID
|
||||
movement_id: Movement ID
|
||||
ingredient_id: Ingredient ID
|
||||
ingredient_name: Ingredient name
|
||||
quantity: Quantity moved
|
||||
unit: Unit of measurement
|
||||
movement_type: Type of movement
|
||||
from_location: Source location (optional)
|
||||
to_location: Destination location (optional)
|
||||
reason: Reason for movement (optional)
|
||||
"""
|
||||
try:
|
||||
# Build message based on movement type
|
||||
if movement_type == "transfer":
|
||||
message = f"Transferred {quantity} {unit} of {ingredient_name}"
|
||||
if from_location and to_location:
|
||||
message += f" from {from_location} to {to_location}"
|
||||
elif movement_type == "adjustment":
|
||||
message = f"Adjusted {ingredient_name} by {quantity} {unit}"
|
||||
if reason:
|
||||
message += f" - {reason}"
|
||||
elif movement_type == "waste":
|
||||
message = f"Waste recorded: {quantity} {unit} of {ingredient_name}"
|
||||
if reason:
|
||||
message += f" - {reason}"
|
||||
elif movement_type == "return":
|
||||
message = f"Returned {quantity} {unit} of {ingredient_name}"
|
||||
else:
|
||||
message = f"Stock movement: {quantity} {unit} of {ingredient_name}"
|
||||
# Build message based on movement type
|
||||
if movement_type == "transfer":
|
||||
message = f"Transferred {quantity} {unit} of {ingredient_name}"
|
||||
if from_location and to_location:
|
||||
message += f" from {from_location} to {to_location}"
|
||||
elif movement_type == "adjustment":
|
||||
message = f"Adjusted {ingredient_name} by {quantity} {unit}"
|
||||
if reason:
|
||||
message += f" - {reason}"
|
||||
elif movement_type == "waste":
|
||||
message = f"Waste recorded: {quantity} {unit} of {ingredient_name}"
|
||||
if reason:
|
||||
message += f" - {reason}"
|
||||
elif movement_type == "return":
|
||||
message = f"Returned {quantity} {unit} of {ingredient_name}"
|
||||
else:
|
||||
message = f"Stock movement: {quantity} {unit} of {ingredient_name}"
|
||||
|
||||
# Create notification event
|
||||
event = RawEvent(
|
||||
tenant_id=tenant_id,
|
||||
event_class=EventClass.NOTIFICATION,
|
||||
event_domain=EventDomain.INVENTORY,
|
||||
event_type="stock_movement",
|
||||
title=f"Stock {movement_type.title()}: {ingredient_name}",
|
||||
message=message,
|
||||
service="inventory",
|
||||
event_metadata={
|
||||
"movement_id": movement_id,
|
||||
"ingredient_id": ingredient_id,
|
||||
"ingredient_name": ingredient_name,
|
||||
"quantity": quantity,
|
||||
"unit": unit,
|
||||
"movement_type": movement_type,
|
||||
"from_location": from_location,
|
||||
"to_location": to_location,
|
||||
"reason": reason,
|
||||
"moved_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
metadata = {
|
||||
"movement_id": movement_id,
|
||||
"ingredient_id": ingredient_id,
|
||||
"ingredient_name": ingredient_name,
|
||||
"quantity": float(quantity),
|
||||
"unit": unit,
|
||||
"movement_type": movement_type,
|
||||
"from_location": from_location,
|
||||
"to_location": to_location,
|
||||
"reason": reason,
|
||||
"moved_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
# Publish to RabbitMQ
|
||||
await self.publish_item(tenant_id, event.dict(), item_type="notification")
|
||||
await self.publisher.publish_notification(
|
||||
event_type="inventory.stock_movement",
|
||||
tenant_id=tenant_id,
|
||||
data=metadata
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Stock movement notification emitted: {movement_type} - {ingredient_name}",
|
||||
extra={"tenant_id": tenant_id, "movement_id": movement_id}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to emit stock movement notification: {e}",
|
||||
extra={"tenant_id": tenant_id, "movement_id": movement_id},
|
||||
exc_info=True,
|
||||
)
|
||||
logger.info(
|
||||
"stock_movement_notification_emitted",
|
||||
tenant_id=str(tenant_id),
|
||||
movement_type=movement_type,
|
||||
ingredient_name=ingredient_name
|
||||
)
|
||||
|
||||
async def emit_stock_updated_notification(
|
||||
self,
|
||||
db: Session,
|
||||
tenant_id: str,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: str,
|
||||
ingredient_name: str,
|
||||
old_quantity: float,
|
||||
@@ -195,52 +139,32 @@ class InventoryNotificationService(BaseAlertService):
|
||||
) -> None:
|
||||
"""
|
||||
Emit notification when stock is updated.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tenant_id: Tenant ID
|
||||
ingredient_id: Ingredient ID
|
||||
ingredient_name: Ingredient name
|
||||
old_quantity: Previous quantity
|
||||
new_quantity: New quantity
|
||||
unit: Unit of measurement
|
||||
update_reason: Reason for update
|
||||
"""
|
||||
try:
|
||||
quantity_change = new_quantity - old_quantity
|
||||
change_direction = "increased" if quantity_change > 0 else "decreased"
|
||||
quantity_change = new_quantity - old_quantity
|
||||
change_direction = "increased" if quantity_change > 0 else "decreased"
|
||||
|
||||
event = RawEvent(
|
||||
tenant_id=tenant_id,
|
||||
event_class=EventClass.NOTIFICATION,
|
||||
event_domain=EventDomain.INVENTORY,
|
||||
event_type="stock_updated",
|
||||
title=f"Stock Updated: {ingredient_name}",
|
||||
message=f"Stock {change_direction} by {abs(quantity_change)} {unit} - {update_reason}",
|
||||
service="inventory",
|
||||
event_metadata={
|
||||
"ingredient_id": ingredient_id,
|
||||
"ingredient_name": ingredient_name,
|
||||
"old_quantity": old_quantity,
|
||||
"new_quantity": new_quantity,
|
||||
"quantity_change": quantity_change,
|
||||
"unit": unit,
|
||||
"update_reason": update_reason,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
message = f"Stock {change_direction} by {abs(quantity_change)} {unit} - {update_reason}"
|
||||
|
||||
await self.publish_item(tenant_id, event.dict(), item_type="notification")
|
||||
metadata = {
|
||||
"ingredient_id": ingredient_id,
|
||||
"ingredient_name": ingredient_name,
|
||||
"old_quantity": float(old_quantity),
|
||||
"new_quantity": float(new_quantity),
|
||||
"quantity_change": float(quantity_change),
|
||||
"unit": unit,
|
||||
"update_reason": update_reason,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Stock updated notification emitted: {ingredient_name}",
|
||||
extra={"tenant_id": tenant_id, "ingredient_id": ingredient_id}
|
||||
)
|
||||
await self.publisher.publish_notification(
|
||||
event_type="inventory.stock_updated",
|
||||
tenant_id=tenant_id,
|
||||
data=metadata
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to emit stock updated notification: {e}",
|
||||
extra={"tenant_id": tenant_id, "ingredient_id": ingredient_id},
|
||||
exc_info=True,
|
||||
)
|
||||
logger.info(
|
||||
"stock_updated_notification_emitted",
|
||||
tenant_id=str(tenant_id),
|
||||
ingredient_name=ingredient_name,
|
||||
quantity_change=quantity_change
|
||||
)
|
||||
@@ -22,6 +22,7 @@ from app.schemas.inventory import (
|
||||
from app.core.database import get_db_transaction
|
||||
from shared.database.exceptions import DatabaseError
|
||||
from shared.utils.batch_generator import BatchNumberGenerator, create_fallback_batch_number
|
||||
from app.utils.cache import delete_cached, make_cache_key
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
@@ -843,6 +844,11 @@ class InventoryService:
|
||||
ingredient_dict['category'] = ingredient.ingredient_category.value if ingredient.ingredient_category else None
|
||||
response.ingredient = IngredientResponse(**ingredient_dict)
|
||||
|
||||
# PHASE 2: Invalidate inventory dashboard cache
|
||||
cache_key = make_cache_key("inventory_dashboard", str(tenant_id))
|
||||
await delete_cached(cache_key)
|
||||
logger.debug("Invalidated inventory dashboard cache", cache_key=cache_key, tenant_id=str(tenant_id))
|
||||
|
||||
logger.info("Stock entry updated successfully", stock_id=stock_id, tenant_id=tenant_id)
|
||||
return response
|
||||
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
# services/inventory/app/services/messaging.py
|
||||
"""
|
||||
Messaging service for inventory events
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from uuid import UUID
|
||||
import structlog
|
||||
|
||||
from shared.messaging.rabbitmq import MessagePublisher
|
||||
from shared.messaging.events import (
|
||||
EVENT_TYPES,
|
||||
InventoryEvent,
|
||||
StockAlertEvent,
|
||||
StockMovementEvent
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class InventoryMessagingService:
|
||||
"""Service for publishing inventory-related events"""
|
||||
|
||||
def __init__(self):
|
||||
self.publisher = MessagePublisher()
|
||||
|
||||
async def publish_ingredient_created(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
ingredient_data: Dict[str, Any]
|
||||
):
|
||||
"""Publish ingredient creation event"""
|
||||
try:
|
||||
event = InventoryEvent(
|
||||
event_type=EVENT_TYPES.INVENTORY.INGREDIENT_CREATED,
|
||||
tenant_id=str(tenant_id),
|
||||
ingredient_id=str(ingredient_id),
|
||||
data=ingredient_data
|
||||
)
|
||||
|
||||
await self.publisher.publish_event(
|
||||
routing_key="inventory.ingredient.created",
|
||||
event=event
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Published ingredient created event",
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to publish ingredient created event",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id
|
||||
)
|
||||
|
||||
async def publish_stock_added(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
stock_id: UUID,
|
||||
quantity: float,
|
||||
batch_number: Optional[str] = None
|
||||
):
|
||||
"""Publish stock addition event"""
|
||||
try:
|
||||
movement_event = StockMovementEvent(
|
||||
event_type=EVENT_TYPES.INVENTORY.STOCK_ADDED,
|
||||
tenant_id=str(tenant_id),
|
||||
ingredient_id=str(ingredient_id),
|
||||
stock_id=str(stock_id),
|
||||
quantity=quantity,
|
||||
movement_type="purchase",
|
||||
data={
|
||||
"batch_number": batch_number,
|
||||
"movement_type": "purchase"
|
||||
}
|
||||
)
|
||||
|
||||
await self.publisher.publish_event(
|
||||
routing_key="inventory.stock.added",
|
||||
event=movement_event
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Published stock added event",
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
quantity=quantity
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to publish stock added event",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id
|
||||
)
|
||||
|
||||
async def publish_stock_consumed(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
consumed_items: list,
|
||||
total_quantity: float,
|
||||
reference_number: Optional[str] = None
|
||||
):
|
||||
"""Publish stock consumption event"""
|
||||
try:
|
||||
for item in consumed_items:
|
||||
movement_event = StockMovementEvent(
|
||||
event_type=EVENT_TYPES.INVENTORY.STOCK_CONSUMED,
|
||||
tenant_id=str(tenant_id),
|
||||
ingredient_id=str(ingredient_id),
|
||||
stock_id=item['stock_id'],
|
||||
quantity=item['quantity_consumed'],
|
||||
movement_type="production_use",
|
||||
data={
|
||||
"batch_number": item.get('batch_number'),
|
||||
"reference_number": reference_number,
|
||||
"movement_type": "production_use"
|
||||
}
|
||||
)
|
||||
|
||||
await self.publisher.publish_event(
|
||||
routing_key="inventory.stock.consumed",
|
||||
event=movement_event
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Published stock consumed events",
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
total_quantity=total_quantity,
|
||||
items_count=len(consumed_items)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to publish stock consumed event",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id
|
||||
)
|
||||
|
||||
async def publish_low_stock_alert(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
ingredient_name: str,
|
||||
current_stock: float,
|
||||
threshold: float,
|
||||
needs_reorder: bool = False
|
||||
):
|
||||
"""Publish low stock alert event"""
|
||||
try:
|
||||
alert_event = StockAlertEvent(
|
||||
event_type=EVENT_TYPES.INVENTORY.LOW_STOCK_ALERT,
|
||||
tenant_id=str(tenant_id),
|
||||
ingredient_id=str(ingredient_id),
|
||||
alert_type="low_stock" if not needs_reorder else "reorder_needed",
|
||||
severity="medium" if not needs_reorder else "high",
|
||||
data={
|
||||
"ingredient_name": ingredient_name,
|
||||
"current_stock": current_stock,
|
||||
"threshold": threshold,
|
||||
"needs_reorder": needs_reorder
|
||||
}
|
||||
)
|
||||
|
||||
await self.publisher.publish_event(
|
||||
routing_key="inventory.alerts.low_stock",
|
||||
event=alert_event
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Published low stock alert",
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
current_stock=current_stock
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to publish low stock alert",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id
|
||||
)
|
||||
|
||||
async def publish_expiration_alert(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
stock_id: UUID,
|
||||
ingredient_name: str,
|
||||
batch_number: Optional[str],
|
||||
expiration_date: str,
|
||||
days_to_expiry: int,
|
||||
quantity: float
|
||||
):
|
||||
"""Publish expiration alert event"""
|
||||
try:
|
||||
severity = "critical" if days_to_expiry <= 1 else "high"
|
||||
|
||||
alert_event = StockAlertEvent(
|
||||
event_type=EVENT_TYPES.INVENTORY.EXPIRATION_ALERT,
|
||||
tenant_id=str(tenant_id),
|
||||
ingredient_id=str(ingredient_id),
|
||||
alert_type="expiring_soon",
|
||||
severity=severity,
|
||||
data={
|
||||
"stock_id": str(stock_id),
|
||||
"ingredient_name": ingredient_name,
|
||||
"batch_number": batch_number,
|
||||
"expiration_date": expiration_date,
|
||||
"days_to_expiry": days_to_expiry,
|
||||
"quantity": quantity
|
||||
}
|
||||
)
|
||||
|
||||
await self.publisher.publish_event(
|
||||
routing_key="inventory.alerts.expiration",
|
||||
event=alert_event
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Published expiration alert",
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
days_to_expiry=days_to_expiry
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to publish expiration alert",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id
|
||||
)
|
||||
26
services/inventory/app/utils/__init__.py
Normal file
26
services/inventory/app/utils/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# services/alert_processor/app/utils/__init__.py
|
||||
"""
|
||||
Utility modules for alert processor service
|
||||
"""
|
||||
|
||||
from .cache import (
|
||||
get_redis_client,
|
||||
close_redis,
|
||||
get_cached,
|
||||
set_cached,
|
||||
delete_cached,
|
||||
delete_pattern,
|
||||
cache_response,
|
||||
make_cache_key,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'get_redis_client',
|
||||
'close_redis',
|
||||
'get_cached',
|
||||
'set_cached',
|
||||
'delete_cached',
|
||||
'delete_pattern',
|
||||
'cache_response',
|
||||
'make_cache_key',
|
||||
]
|
||||
265
services/inventory/app/utils/cache.py
Normal file
265
services/inventory/app/utils/cache.py
Normal file
@@ -0,0 +1,265 @@
|
||||
# services/orchestrator/app/utils/cache.py
|
||||
"""
|
||||
Redis caching utilities for dashboard endpoints
|
||||
"""
|
||||
|
||||
import json
|
||||
import redis.asyncio as redis
|
||||
from typing import Optional, Any, Callable
|
||||
from functools import wraps
|
||||
import structlog
|
||||
from app.core.config import settings
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Redis client instance
|
||||
_redis_client: Optional[redis.Redis] = None
|
||||
|
||||
|
||||
async def get_redis_client() -> redis.Redis:
|
||||
"""Get or create Redis client"""
|
||||
global _redis_client
|
||||
|
||||
if _redis_client is None:
|
||||
try:
|
||||
# Check if TLS is enabled - convert string to boolean properly
|
||||
redis_tls_str = str(getattr(settings, 'REDIS_TLS_ENABLED', 'false')).lower()
|
||||
redis_tls_enabled = redis_tls_str in ('true', '1', 'yes', 'on')
|
||||
|
||||
connection_kwargs = {
|
||||
'host': str(getattr(settings, 'REDIS_HOST', 'localhost')),
|
||||
'port': int(getattr(settings, 'REDIS_PORT', 6379)),
|
||||
'db': int(getattr(settings, 'REDIS_DB', 0)),
|
||||
'decode_responses': True,
|
||||
'socket_connect_timeout': 5,
|
||||
'socket_timeout': 5
|
||||
}
|
||||
|
||||
# Add password if configured
|
||||
redis_password = getattr(settings, 'REDIS_PASSWORD', None)
|
||||
if redis_password:
|
||||
connection_kwargs['password'] = redis_password
|
||||
|
||||
# Add SSL/TLS support if enabled
|
||||
if redis_tls_enabled:
|
||||
import ssl
|
||||
connection_kwargs['ssl'] = True
|
||||
connection_kwargs['ssl_cert_reqs'] = ssl.CERT_NONE
|
||||
logger.debug(f"Redis TLS enabled - connecting with SSL to {connection_kwargs['host']}:{connection_kwargs['port']}")
|
||||
|
||||
_redis_client = redis.Redis(**connection_kwargs)
|
||||
|
||||
# Test connection
|
||||
await _redis_client.ping()
|
||||
logger.info(f"Redis client connected successfully (TLS: {redis_tls_enabled})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to connect to Redis: {e}. Caching will be disabled.")
|
||||
_redis_client = None
|
||||
|
||||
return _redis_client
|
||||
|
||||
|
||||
async def close_redis():
|
||||
"""Close Redis connection"""
|
||||
global _redis_client
|
||||
if _redis_client:
|
||||
await _redis_client.close()
|
||||
_redis_client = None
|
||||
logger.info("Redis connection closed")
|
||||
|
||||
|
||||
async def get_cached(key: str) -> Optional[Any]:
|
||||
"""
|
||||
Get cached value by key
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
|
||||
Returns:
|
||||
Cached value (deserialized from JSON) or None if not found or error
|
||||
"""
|
||||
try:
|
||||
client = await get_redis_client()
|
||||
if not client:
|
||||
return None
|
||||
|
||||
cached = await client.get(key)
|
||||
if cached:
|
||||
logger.debug(f"Cache hit: {key}")
|
||||
return json.loads(cached)
|
||||
else:
|
||||
logger.debug(f"Cache miss: {key}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache get error for key {key}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _serialize_value(value: Any) -> Any:
|
||||
"""
|
||||
Recursively serialize values for JSON storage, handling Pydantic models properly.
|
||||
|
||||
Args:
|
||||
value: Value to serialize
|
||||
|
||||
Returns:
|
||||
JSON-serializable value
|
||||
"""
|
||||
if isinstance(value, BaseModel):
|
||||
# Convert Pydantic model to dictionary
|
||||
return value.model_dump()
|
||||
elif isinstance(value, (list, tuple)):
|
||||
# Recursively serialize list/tuple elements
|
||||
return [_serialize_value(item) for item in value]
|
||||
elif isinstance(value, dict):
|
||||
# Recursively serialize dictionary values
|
||||
return {key: _serialize_value(val) for key, val in value.items()}
|
||||
else:
|
||||
# For other types, use default serialization
|
||||
return value
|
||||
|
||||
|
||||
async def set_cached(key: str, value: Any, ttl: int = 60) -> bool:
|
||||
"""
|
||||
Set cached value with TTL
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
value: Value to cache (will be JSON serialized)
|
||||
ttl: Time to live in seconds
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = await get_redis_client()
|
||||
if not client:
|
||||
return False
|
||||
|
||||
# Serialize value properly before JSON encoding
|
||||
serialized_value = _serialize_value(value)
|
||||
serialized = json.dumps(serialized_value)
|
||||
await client.setex(key, ttl, serialized)
|
||||
logger.debug(f"Cache set: {key} (TTL: {ttl}s)")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache set error for key {key}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def delete_cached(key: str) -> bool:
|
||||
"""
|
||||
Delete cached value
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = await get_redis_client()
|
||||
if not client:
|
||||
return False
|
||||
|
||||
await client.delete(key)
|
||||
logger.debug(f"Cache deleted: {key}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache delete error for key {key}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def delete_pattern(pattern: str) -> int:
|
||||
"""
|
||||
Delete all keys matching pattern
|
||||
|
||||
Args:
|
||||
pattern: Redis key pattern (e.g., "dashboard:*")
|
||||
|
||||
Returns:
|
||||
Number of keys deleted
|
||||
"""
|
||||
try:
|
||||
client = await get_redis_client()
|
||||
if not client:
|
||||
return 0
|
||||
|
||||
keys = []
|
||||
async for key in client.scan_iter(match=pattern):
|
||||
keys.append(key)
|
||||
|
||||
if keys:
|
||||
deleted = await client.delete(*keys)
|
||||
logger.info(f"Deleted {deleted} keys matching pattern: {pattern}")
|
||||
return deleted
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache delete pattern error for {pattern}: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def cache_response(key_prefix: str, ttl: int = 60):
|
||||
"""
|
||||
Decorator to cache endpoint responses
|
||||
|
||||
Args:
|
||||
key_prefix: Prefix for cache key (will be combined with tenant_id)
|
||||
ttl: Time to live in seconds
|
||||
|
||||
Usage:
|
||||
@cache_response("dashboard:health", ttl=30)
|
||||
async def get_health(tenant_id: str):
|
||||
...
|
||||
"""
|
||||
def decorator(func: Callable):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Extract tenant_id from kwargs or args
|
||||
tenant_id = kwargs.get('tenant_id')
|
||||
if not tenant_id and args:
|
||||
# Try to find tenant_id in args (assuming it's the first argument)
|
||||
tenant_id = args[0] if len(args) > 0 else None
|
||||
|
||||
if not tenant_id:
|
||||
# No tenant_id, skip caching
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Build cache key
|
||||
cache_key = f"{key_prefix}:{tenant_id}"
|
||||
|
||||
# Try to get from cache
|
||||
cached_value = await get_cached(cache_key)
|
||||
if cached_value is not None:
|
||||
return cached_value
|
||||
|
||||
# Execute function
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# Cache result
|
||||
await set_cached(cache_key, result, ttl)
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def make_cache_key(prefix: str, tenant_id: str, **params) -> str:
|
||||
"""
|
||||
Create a cache key with optional parameters
|
||||
|
||||
Args:
|
||||
prefix: Key prefix
|
||||
tenant_id: Tenant ID
|
||||
**params: Additional parameters to include in key
|
||||
|
||||
Returns:
|
||||
Cache key string
|
||||
"""
|
||||
key_parts = [prefix, tenant_id]
|
||||
for k, v in sorted(params.items()):
|
||||
if v is not None:
|
||||
key_parts.append(f"{k}:{v}")
|
||||
return ":".join(key_parts)
|
||||
Reference in New Issue
Block a user