# ================================================================ # services/inventory/app/api/food_safety.py # ================================================================ """ Food Safety API endpoints for Inventory Service """ from datetime import datetime, timedelta 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 structlog from shared.auth.decorators import get_current_user_dep from app.core.database import get_db from app.services.food_safety_service import FoodSafetyService from app.schemas.food_safety import ( FoodSafetyComplianceCreate, FoodSafetyComplianceUpdate, FoodSafetyComplianceResponse, TemperatureLogCreate, TemperatureLogResponse, FoodSafetyAlertCreate, FoodSafetyAlertUpdate, FoodSafetyAlertResponse, BulkTemperatureLogCreate, FoodSafetyFilter, TemperatureMonitoringFilter, FoodSafetyMetrics, TemperatureAnalytics ) logger = structlog.get_logger() router = APIRouter(prefix="/food-safety", tags=["food-safety"]) # ===== Dependency Injection ===== async def get_food_safety_service() -> FoodSafetyService: """Get food safety service instance""" return FoodSafetyService() # ===== Compliance Management Endpoints ===== @router.post("/tenants/{tenant_id}/compliance", response_model=FoodSafetyComplianceResponse, status_code=status.HTTP_201_CREATED) async def create_compliance_record( compliance_data: FoodSafetyComplianceCreate, tenant_id: UUID = Path(...), current_user: dict = Depends(get_current_user_dep), food_safety_service: FoodSafetyService = Depends(get_food_safety_service), db: AsyncSession = Depends(get_db) ): """Create a new food safety compliance record""" try: # Ensure tenant_id matches compliance_data.tenant_id = tenant_id compliance = await food_safety_service.create_compliance_record( db, compliance_data, user_id=UUID(current_user["sub"]) ) logger.info("Compliance record created", compliance_id=str(compliance.id), standard=compliance.standard) return compliance except ValueError as e: logger.warning("Invalid compliance data", error=str(e)) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e) ) except Exception as e: logger.error("Error creating compliance record", error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create compliance record" ) @router.get("/tenants/{tenant_id}/compliance", response_model=List[FoodSafetyComplianceResponse]) async def get_compliance_records( tenant_id: UUID = Path(...), ingredient_id: Optional[UUID] = Query(None, description="Filter by ingredient ID"), standard: Optional[str] = Query(None, description="Filter by compliance standard"), status_filter: Optional[str] = Query(None, description="Filter by compliance status"), 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"), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Get compliance records with filtering""" try: # Build query filters filters = {} if ingredient_id: filters["ingredient_id"] = ingredient_id if standard: filters["standard"] = standard if status_filter: filters["compliance_status"] = status_filter # Query compliance records query = """ SELECT * FROM food_safety_compliance WHERE tenant_id = :tenant_id AND is_active = true """ params = {"tenant_id": tenant_id} if filters: for key, value in filters.items(): query += f" AND {key} = :{key}" params[key] = value query += " ORDER BY created_at DESC LIMIT :limit OFFSET :skip" params.update({"limit": limit, "skip": skip}) result = await db.execute(query, params) records = result.fetchall() return [ FoodSafetyComplianceResponse(**dict(record)) for record in records ] except Exception as e: logger.error("Error getting compliance records", error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve compliance records" ) @router.put("/tenants/{tenant_id}/compliance/{compliance_id}", response_model=FoodSafetyComplianceResponse) async def update_compliance_record( compliance_data: FoodSafetyComplianceUpdate, tenant_id: UUID = Path(...), compliance_id: UUID = Path(...), current_user: dict = Depends(get_current_user_dep), food_safety_service: FoodSafetyService = Depends(get_food_safety_service), db: AsyncSession = Depends(get_db) ): """Update an existing compliance record""" try: compliance = await food_safety_service.update_compliance_record( db, compliance_id, tenant_id, compliance_data, user_id=UUID(current_user["sub"]) ) if not compliance: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Compliance record not found" ) logger.info("Compliance record updated", compliance_id=str(compliance.id)) return compliance except HTTPException: raise except Exception as e: logger.error("Error updating compliance record", compliance_id=str(compliance_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update compliance record" ) # ===== Temperature Monitoring Endpoints ===== @router.post("/tenants/{tenant_id}/temperature", response_model=TemperatureLogResponse, status_code=status.HTTP_201_CREATED) async def log_temperature( temp_data: TemperatureLogCreate, tenant_id: UUID = Path(...), current_user: dict = Depends(get_current_user_dep), food_safety_service: FoodSafetyService = Depends(get_food_safety_service), db: AsyncSession = Depends(get_db) ): """Log a temperature reading""" try: # Ensure tenant_id matches temp_data.tenant_id = tenant_id temp_log = await food_safety_service.log_temperature( db, temp_data, user_id=UUID(current_user["sub"]) ) logger.info("Temperature logged", location=temp_data.storage_location, temperature=temp_data.temperature_celsius) return temp_log except Exception as e: logger.error("Error logging temperature", error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to log temperature" ) @router.post("/tenants/{tenant_id}/temperature/bulk", response_model=List[TemperatureLogResponse]) async def bulk_log_temperatures( bulk_data: BulkTemperatureLogCreate, tenant_id: UUID = Path(...), current_user: dict = Depends(get_current_user_dep), food_safety_service: FoodSafetyService = Depends(get_food_safety_service), db: AsyncSession = Depends(get_db) ): """Bulk log temperature readings""" try: # Ensure tenant_id matches for all readings for reading in bulk_data.readings: reading.tenant_id = tenant_id temp_logs = await food_safety_service.bulk_log_temperatures( db, bulk_data.readings, user_id=UUID(current_user["sub"]) ) logger.info("Bulk temperature logging completed", count=len(bulk_data.readings)) return temp_logs except Exception as e: logger.error("Error bulk logging temperatures", error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to bulk log temperatures" ) @router.get("/tenants/{tenant_id}/temperature", response_model=List[TemperatureLogResponse]) async def get_temperature_logs( tenant_id: UUID = Path(...), location: Optional[str] = Query(None, description="Filter by storage location"), equipment_id: Optional[str] = Query(None, description="Filter by equipment ID"), date_from: Optional[datetime] = Query(None, description="Start date for filtering"), date_to: Optional[datetime] = Query(None, description="End date for filtering"), violations_only: bool = Query(False, description="Show only temperature violations"), 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"), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Get temperature logs with filtering""" try: # Build query where_conditions = ["tenant_id = :tenant_id"] params = {"tenant_id": tenant_id} if location: where_conditions.append("storage_location ILIKE :location") params["location"] = f"%{location}%" if equipment_id: where_conditions.append("equipment_id = :equipment_id") params["equipment_id"] = equipment_id if date_from: where_conditions.append("recorded_at >= :date_from") params["date_from"] = date_from if date_to: where_conditions.append("recorded_at <= :date_to") params["date_to"] = date_to if violations_only: where_conditions.append("is_within_range = false") where_clause = " AND ".join(where_conditions) query = f""" SELECT * FROM temperature_logs WHERE {where_clause} ORDER BY recorded_at DESC LIMIT :limit OFFSET :skip """ params.update({"limit": limit, "skip": skip}) result = await db.execute(query, params) logs = result.fetchall() return [ TemperatureLogResponse(**dict(log)) for log in logs ] except Exception as e: logger.error("Error getting temperature logs", error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve temperature logs" ) # ===== Alert Management Endpoints ===== @router.post("/tenants/{tenant_id}/alerts", response_model=FoodSafetyAlertResponse, status_code=status.HTTP_201_CREATED) async def create_food_safety_alert( alert_data: FoodSafetyAlertCreate, tenant_id: UUID = Path(...), current_user: dict = Depends(get_current_user_dep), food_safety_service: FoodSafetyService = Depends(get_food_safety_service), db: AsyncSession = Depends(get_db) ): """Create a food safety alert""" try: # Ensure tenant_id matches alert_data.tenant_id = tenant_id alert = await food_safety_service.create_food_safety_alert( db, alert_data, user_id=UUID(current_user["sub"]) ) logger.info("Food safety alert created", alert_id=str(alert.id), alert_type=alert.alert_type) return alert except Exception as e: logger.error("Error creating food safety alert", error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create food safety alert" ) @router.get("/tenants/{tenant_id}/alerts", response_model=List[FoodSafetyAlertResponse]) async def get_food_safety_alerts( tenant_id: UUID = Path(...), alert_type: Optional[str] = Query(None, description="Filter by alert type"), severity: Optional[str] = Query(None, description="Filter by severity"), status_filter: Optional[str] = Query(None, description="Filter by status"), unresolved_only: bool = Query(True, description="Show only unresolved alerts"), skip: int = Query(0, ge=0, description="Number of alerts to skip"), limit: int = Query(100, ge=1, le=1000, description="Number of alerts to return"), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Get food safety alerts with filtering""" try: # Build query filters where_conditions = ["tenant_id = :tenant_id"] params = {"tenant_id": tenant_id} if alert_type: where_conditions.append("alert_type = :alert_type") params["alert_type"] = alert_type if severity: where_conditions.append("severity = :severity") params["severity"] = severity if status_filter: where_conditions.append("status = :status") params["status"] = status_filter elif unresolved_only: where_conditions.append("status NOT IN ('resolved', 'dismissed')") where_clause = " AND ".join(where_conditions) query = f""" SELECT * FROM food_safety_alerts WHERE {where_clause} ORDER BY created_at DESC LIMIT :limit OFFSET :skip """ params.update({"limit": limit, "skip": skip}) result = await db.execute(query, params) alerts = result.fetchall() return [ FoodSafetyAlertResponse(**dict(alert)) for alert in alerts ] except Exception as e: logger.error("Error getting food safety alerts", error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve food safety alerts" ) @router.put("/tenants/{tenant_id}/alerts/{alert_id}", response_model=FoodSafetyAlertResponse) async def update_food_safety_alert( alert_data: FoodSafetyAlertUpdate, tenant_id: UUID = Path(...), alert_id: UUID = Path(...), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Update a food safety alert""" try: # Get existing alert alert_query = "SELECT * FROM food_safety_alerts WHERE id = :alert_id AND tenant_id = :tenant_id" result = await db.execute(alert_query, {"alert_id": alert_id, "tenant_id": tenant_id}) alert_record = result.fetchone() if not alert_record: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Food safety alert not found" ) # Update alert fields update_fields = alert_data.dict(exclude_unset=True) if update_fields: set_clauses = [] params = {"alert_id": alert_id, "tenant_id": tenant_id} for field, value in update_fields.items(): set_clauses.append(f"{field} = :{field}") params[field] = value # Add updated timestamp and user set_clauses.append("updated_at = NOW()") set_clauses.append("updated_by = :updated_by") params["updated_by"] = UUID(current_user["sub"]) update_query = f""" UPDATE food_safety_alerts SET {', '.join(set_clauses)} WHERE id = :alert_id AND tenant_id = :tenant_id """ await db.execute(update_query, params) await db.commit() # Get updated alert result = await db.execute(alert_query, {"alert_id": alert_id, "tenant_id": tenant_id}) updated_alert = result.fetchone() logger.info("Food safety alert updated", alert_id=str(alert_id)) return FoodSafetyAlertResponse(**dict(updated_alert)) except HTTPException: raise except Exception as e: logger.error("Error updating food safety alert", alert_id=str(alert_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update food safety alert" ) @router.post("/tenants/{tenant_id}/alerts/{alert_id}/acknowledge") async def acknowledge_alert( tenant_id: UUID = Path(...), alert_id: UUID = Path(...), notes: Optional[str] = Query(None, description="Acknowledgment notes"), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Acknowledge a food safety alert""" try: # Update alert to acknowledged status update_query = """ UPDATE food_safety_alerts SET status = 'acknowledged', acknowledged_at = NOW(), acknowledged_by = :user_id, investigation_notes = COALESCE(investigation_notes, '') || :notes, updated_at = NOW(), updated_by = :user_id WHERE id = :alert_id AND tenant_id = :tenant_id """ result = await db.execute(update_query, { "alert_id": alert_id, "tenant_id": tenant_id, "user_id": UUID(current_user["sub"]), "notes": f"\nAcknowledged: {notes}" if notes else "\nAcknowledged" }) if result.rowcount == 0: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Food safety alert not found" ) await db.commit() logger.info("Food safety alert acknowledged", alert_id=str(alert_id)) return {"message": "Alert acknowledged successfully"} except HTTPException: raise except Exception as e: logger.error("Error acknowledging alert", alert_id=str(alert_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to acknowledge alert" ) # ===== Analytics and Reporting Endpoints ===== @router.get("/tenants/{tenant_id}/metrics", response_model=FoodSafetyMetrics) async def get_food_safety_metrics( tenant_id: UUID = Path(...), days_back: int = Query(30, ge=1, le=365, description="Number of days to analyze"), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Get food safety performance metrics""" try: # Calculate compliance rate compliance_query = """ SELECT COUNT(*) as total, COUNT(CASE WHEN compliance_status = 'compliant' THEN 1 END) as compliant FROM food_safety_compliance WHERE tenant_id = :tenant_id AND is_active = true """ result = await db.execute(compliance_query, {"tenant_id": tenant_id}) compliance_stats = result.fetchone() compliance_rate = 0.0 if compliance_stats.total > 0: compliance_rate = (compliance_stats.compliant / compliance_stats.total) * 100 # Calculate temperature compliance temp_query = """ SELECT COUNT(*) as total_readings, COUNT(CASE WHEN is_within_range THEN 1 END) as compliant_readings FROM temperature_logs WHERE tenant_id = :tenant_id AND recorded_at > NOW() - INTERVAL '%s days' """ % days_back result = await db.execute(temp_query, {"tenant_id": tenant_id}) temp_stats = result.fetchone() temp_compliance_rate = 0.0 if temp_stats.total_readings > 0: temp_compliance_rate = (temp_stats.compliant_readings / temp_stats.total_readings) * 100 # Get alert metrics alert_query = """ SELECT COUNT(*) as total_alerts, COUNT(CASE WHEN is_recurring THEN 1 END) as recurring_alerts, COUNT(CASE WHEN regulatory_action_required THEN 1 END) as regulatory_violations, AVG(CASE WHEN response_time_minutes IS NOT NULL THEN response_time_minutes END) as avg_response_time, AVG(CASE WHEN resolution_time_minutes IS NOT NULL THEN resolution_time_minutes END) as avg_resolution_time FROM food_safety_alerts WHERE tenant_id = :tenant_id AND created_at > NOW() - INTERVAL '%s days' """ % days_back result = await db.execute(alert_query, {"tenant_id": tenant_id}) alert_stats = result.fetchone() return FoodSafetyMetrics( compliance_rate=Decimal(str(compliance_rate)), temperature_compliance_rate=Decimal(str(temp_compliance_rate)), alert_response_time_avg=Decimal(str(alert_stats.avg_response_time or 0)), alert_resolution_time_avg=Decimal(str(alert_stats.avg_resolution_time or 0)), recurring_issues_count=alert_stats.recurring_alerts or 0, regulatory_violations=alert_stats.regulatory_violations or 0, certification_coverage=Decimal(str(compliance_rate)), # Same as compliance rate for now audit_score_avg=Decimal("85.0"), # Would calculate from actual audit data risk_score=Decimal("3.2") # Would calculate from risk assessments ) except Exception as e: logger.error("Error getting food safety metrics", error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve food safety metrics" ) # ===== Health and Status Endpoints ===== @router.get("/tenants/{tenant_id}/status") async def get_food_safety_status( tenant_id: UUID = Path(...), current_user: dict = Depends(get_current_user_dep) ): """Get food safety service status""" try: return { "service": "food-safety", "status": "healthy", "timestamp": datetime.now().isoformat(), "tenant_id": str(tenant_id), "features": { "compliance_tracking": "enabled", "temperature_monitoring": "enabled", "automated_alerts": "enabled", "regulatory_reporting": "enabled" } } except Exception as e: logger.error("Error getting food safety status", error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get food safety status" )