Files
bakery-ia/services/alert_processor/app/api/analytics.py
2025-11-27 15:52:40 +01:00

521 lines
20 KiB
Python

"""
Alert Analytics API Endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, Path, Body, Query
from typing import List, Dict, Any, Optional
from uuid import UUID
from pydantic import BaseModel, Field
import structlog
from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import service_only_access
logger = structlog.get_logger()
router = APIRouter()
# Schemas
class InteractionCreate(BaseModel):
"""Schema for creating an alert interaction"""
alert_id: str = Field(..., description="Alert ID")
interaction_type: str = Field(..., description="Type of interaction: acknowledged, resolved, snoozed, dismissed")
metadata: Optional[Dict[str, Any]] = Field(None, description="Additional metadata")
class InteractionBatchCreate(BaseModel):
"""Schema for creating multiple interactions"""
interactions: List[Dict[str, Any]] = Field(..., description="List of interactions to create")
class AnalyticsResponse(BaseModel):
"""Schema for analytics response"""
trends: List[Dict[str, Any]]
averageResponseTime: int
topCategories: List[Dict[str, Any]]
totalAlerts: int
resolvedAlerts: int
activeAlerts: int
resolutionRate: int
predictedDailyAverage: int
busiestDay: str
def get_analytics_repository(current_user: dict = Depends(get_current_user_dep)):
"""Dependency to get analytics repository"""
from app.repositories.analytics_repository import AlertAnalyticsRepository
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async def _get_repo():
async with db_manager.get_session() as session:
yield AlertAnalyticsRepository(session)
return _get_repo
@router.post(
"/api/v1/tenants/{tenant_id}/alerts/{alert_id}/interactions",
response_model=Dict[str, Any],
summary="Track alert interaction"
)
async def create_interaction(
tenant_id: UUID = Path(..., description="Tenant ID"),
alert_id: UUID = Path(..., description="Alert ID"),
interaction: InteractionCreate = Body(...),
current_user: dict = Depends(get_current_user_dep)
):
"""
Track a user interaction with an alert
- **acknowledged**: User has seen and acknowledged the alert
- **resolved**: User has resolved the alert
- **snoozed**: User has snoozed the alert
- **dismissed**: User has dismissed the alert
"""
from app.repositories.analytics_repository import AlertAnalyticsRepository
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
try:
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
repo = AlertAnalyticsRepository(session)
alert_interaction = await repo.create_interaction(
tenant_id=tenant_id,
alert_id=alert_id,
user_id=UUID(current_user['user_id']),
interaction_type=interaction.interaction_type,
metadata=interaction.metadata
)
return {
'id': str(alert_interaction.id),
'alert_id': str(alert_interaction.alert_id),
'interaction_type': alert_interaction.interaction_type,
'interacted_at': alert_interaction.interacted_at.isoformat(),
'response_time_seconds': alert_interaction.response_time_seconds
}
except ValueError as e:
logger.error("Invalid alert interaction", error=str(e), alert_id=str(alert_id))
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to create alert interaction", error=str(e), alert_id=str(alert_id))
raise HTTPException(status_code=500, detail=f"Failed to create interaction: {str(e)}")
@router.post(
"/api/v1/tenants/{tenant_id}/alerts/interactions/batch",
response_model=Dict[str, Any],
summary="Track multiple alert interactions"
)
async def create_interactions_batch(
tenant_id: UUID = Path(..., description="Tenant ID"),
batch: InteractionBatchCreate = Body(...),
current_user: dict = Depends(get_current_user_dep)
):
"""
Track multiple alert interactions in a single request
Useful for offline sync or bulk operations
"""
from app.repositories.analytics_repository import AlertAnalyticsRepository
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
try:
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
repo = AlertAnalyticsRepository(session)
# Add user_id to each interaction
for interaction in batch.interactions:
interaction['user_id'] = current_user['user_id']
created_interactions = await repo.create_interactions_batch(
tenant_id=tenant_id,
interactions=batch.interactions
)
return {
'created_count': len(created_interactions),
'interactions': [
{
'id': str(i.id),
'alert_id': str(i.alert_id),
'interaction_type': i.interaction_type,
'interacted_at': i.interacted_at.isoformat()
}
for i in created_interactions
]
}
except Exception as e:
logger.error("Failed to create batch interactions", error=str(e), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail=f"Failed to create batch interactions: {str(e)}")
@router.get(
"/api/v1/tenants/{tenant_id}/alerts/analytics",
response_model=AnalyticsResponse,
summary="Get alert analytics"
)
async def get_analytics(
tenant_id: UUID = Path(..., description="Tenant ID"),
days: int = Query(7, ge=1, le=90, description="Number of days to analyze"),
current_user: dict = Depends(get_current_user_dep)
):
"""
Get comprehensive analytics for alerts
Returns:
- 7-day trend chart with severity breakdown
- Average response time (time to acknowledgment)
- Top 3 alert categories
- Total alerts, resolved, active counts
- Resolution rate percentage
- Predicted daily average
- Busiest day of week
"""
from app.repositories.analytics_repository import AlertAnalyticsRepository
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
try:
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
repo = AlertAnalyticsRepository(session)
analytics = await repo.get_full_analytics(
tenant_id=tenant_id,
days=days
)
return analytics
except Exception as e:
logger.error("Failed to get alert analytics", error=str(e), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail=f"Failed to get analytics: {str(e)}")
@router.get(
"/api/v1/tenants/{tenant_id}/alerts/analytics/trends",
response_model=List[Dict[str, Any]],
summary="Get alert trends"
)
async def get_trends(
tenant_id: UUID = Path(..., description="Tenant ID"),
days: int = Query(7, ge=1, le=90, description="Number of days to analyze"),
current_user: dict = Depends(get_current_user_dep)
):
"""Get alert trends over time with severity breakdown"""
from app.repositories.analytics_repository import AlertAnalyticsRepository
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
try:
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
repo = AlertAnalyticsRepository(session)
trends = await repo.get_analytics_trends(
tenant_id=tenant_id,
days=days
)
return trends
except Exception as e:
logger.error("Failed to get alert trends", error=str(e), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail=f"Failed to get trends: {str(e)}")
@router.get(
"/api/v1/tenants/{tenant_id}/alerts/analytics/dashboard",
response_model=Dict[str, Any],
summary="Get enriched alert analytics for dashboard"
)
async def get_dashboard_analytics(
tenant_id: UUID = Path(..., description="Tenant ID"),
days: int = Query(30, ge=1, le=90, description="Number of days to analyze"),
current_user: dict = Depends(get_current_user_dep)
):
"""
Get enriched alert analytics optimized for dashboard display.
Returns metrics based on the new enrichment system:
- AI handling rate (% of prevented_issue alerts)
- Priority distribution (critical, important, standard, info)
- Type class breakdown (action_needed, prevented_issue, trend_warning, etc.)
- Total financial impact at risk
- Average response time by priority level
- Prevented issues and estimated savings
"""
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
from app.models.events import Alert, AlertStatus, AlertTypeClass, PriorityLevel
from sqlalchemy import select, func, and_
from datetime import datetime, timedelta
try:
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
cutoff_date = datetime.utcnow() - timedelta(days=days)
# Total alerts
total_query = select(func.count(Alert.id)).where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= cutoff_date
)
)
total_result = await session.execute(total_query)
total_alerts = total_result.scalar() or 0
# Priority distribution
priority_query = select(
Alert.priority_level,
func.count(Alert.id).label('count')
).where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= cutoff_date
)
).group_by(Alert.priority_level)
priority_result = await session.execute(priority_query)
priority_dist = {row.priority_level: row.count for row in priority_result}
# Type class distribution
type_class_query = select(
Alert.type_class,
func.count(Alert.id).label('count')
).where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= cutoff_date
)
).group_by(Alert.type_class)
type_class_result = await session.execute(type_class_query)
type_class_dist = {row.type_class: row.count for row in type_class_result}
# AI handling metrics
prevented_count = type_class_dist.get(AlertTypeClass.PREVENTED_ISSUE, 0)
ai_handling_percentage = (prevented_count / total_alerts * 100) if total_alerts > 0 else 0
# Financial impact - sum all business_impact.financial_impact_eur from active alerts
active_alerts_query = select(Alert.id, Alert.business_impact).where(
and_(
Alert.tenant_id == tenant_id,
Alert.status == AlertStatus.ACTIVE
)
)
active_alerts_result = await session.execute(active_alerts_query)
active_alerts = active_alerts_result.all()
total_financial_impact = sum(
(alert.business_impact or {}).get('financial_impact_eur', 0)
for alert in active_alerts
)
# Prevented issues savings
prevented_alerts_query = select(Alert.id, Alert.orchestrator_context).where(
and_(
Alert.tenant_id == tenant_id,
Alert.type_class == 'prevented_issue',
Alert.created_at >= cutoff_date
)
)
prevented_alerts_result = await session.execute(prevented_alerts_query)
prevented_alerts = prevented_alerts_result.all()
estimated_savings = sum(
(alert.orchestrator_context or {}).get('estimated_savings_eur', 0)
for alert in prevented_alerts
)
# Active alerts by type class
active_by_type_query = select(
Alert.type_class,
func.count(Alert.id).label('count')
).where(
and_(
Alert.tenant_id == tenant_id,
Alert.status == AlertStatus.ACTIVE
)
).group_by(Alert.type_class)
active_by_type_result = await session.execute(active_by_type_query)
active_by_type = {row.type_class: row.count for row in active_by_type_result}
# Get period comparison for trends
from app.repositories.analytics_repository import AlertAnalyticsRepository
analytics_repo = AlertAnalyticsRepository(session)
period_comparison = await analytics_repo.get_period_comparison(
tenant_id=tenant_id,
current_days=days,
previous_days=days
)
return {
"period_days": days,
"total_alerts": total_alerts,
"active_alerts": len(active_alerts),
"ai_handling_rate": round(ai_handling_percentage, 1),
"prevented_issues_count": prevented_count,
"estimated_savings_eur": round(estimated_savings, 2),
"total_financial_impact_at_risk_eur": round(total_financial_impact, 2),
"priority_distribution": {
"critical": priority_dist.get(PriorityLevel.CRITICAL, 0),
"important": priority_dist.get(PriorityLevel.IMPORTANT, 0),
"standard": priority_dist.get(PriorityLevel.STANDARD, 0),
"info": priority_dist.get(PriorityLevel.INFO, 0)
},
"type_class_distribution": {
"action_needed": type_class_dist.get(AlertTypeClass.ACTION_NEEDED, 0),
"prevented_issue": type_class_dist.get(AlertTypeClass.PREVENTED_ISSUE, 0),
"trend_warning": type_class_dist.get(AlertTypeClass.TREND_WARNING, 0),
"escalation": type_class_dist.get(AlertTypeClass.ESCALATION, 0),
"information": type_class_dist.get(AlertTypeClass.INFORMATION, 0)
},
"active_by_type_class": active_by_type,
"period_comparison": period_comparison
}
except Exception as e:
logger.error("Failed to get dashboard analytics", error=str(e), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail=f"Failed to get dashboard analytics: {str(e)}")
# ============================================================================
# Tenant Data Deletion Operations (Internal Service Only)
# ============================================================================
@router.delete(
"/api/v1/alerts/tenant/{tenant_id}",
response_model=dict
)
@service_only_access
async def delete_tenant_data(
tenant_id: str = Path(..., description="Tenant ID to delete data for"),
current_user: dict = Depends(get_current_user_dep)
):
"""
Delete all alert data for a tenant (Internal service only)
This endpoint is called by the orchestrator during tenant deletion.
It permanently deletes all alert-related data including:
- Alerts (all types and severities)
- Alert interactions
- Audit logs
**WARNING**: This operation is irreversible!
Returns:
Deletion summary with counts of deleted records
"""
from app.services.tenant_deletion_service import AlertProcessorTenantDeletionService
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
try:
logger.info("alert_processor.tenant_deletion.api_called", tenant_id=tenant_id)
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
deletion_service = AlertProcessorTenantDeletionService(session)
result = await deletion_service.safe_delete_tenant_data(tenant_id)
if not result.success:
raise HTTPException(
status_code=500,
detail=f"Tenant data deletion failed: {', '.join(result.errors)}"
)
return {
"message": "Tenant data deletion completed successfully",
"summary": result.to_dict()
}
except HTTPException:
raise
except Exception as e:
logger.error("alert_processor.tenant_deletion.api_error",
tenant_id=tenant_id,
error=str(e),
exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to delete tenant data: {str(e)}"
)
@router.get(
"/api/v1/alerts/tenant/{tenant_id}/deletion-preview",
response_model=dict
)
@service_only_access
async def preview_tenant_data_deletion(
tenant_id: str = Path(..., description="Tenant ID to preview deletion for"),
current_user: dict = Depends(get_current_user_dep)
):
"""
Preview what data would be deleted for a tenant (dry-run)
This endpoint shows counts of all data that would be deleted
without actually deleting anything. Useful for:
- Confirming deletion scope before execution
- Auditing and compliance
- Troubleshooting
Returns:
Dictionary with entity names and their counts
"""
from app.services.tenant_deletion_service import AlertProcessorTenantDeletionService
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
try:
logger.info("alert_processor.tenant_deletion.preview_called", tenant_id=tenant_id)
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
deletion_service = AlertProcessorTenantDeletionService(session)
preview = await deletion_service.get_tenant_data_preview(tenant_id)
total_records = sum(preview.values())
return {
"tenant_id": tenant_id,
"service": "alert_processor",
"preview": preview,
"total_records": total_records,
"warning": "These records will be permanently deleted and cannot be recovered"
}
except Exception as e:
logger.error("alert_processor.tenant_deletion.preview_error",
tenant_id=tenant_id,
error=str(e),
exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to preview tenant data deletion: {str(e)}"
)