New alert service
This commit is contained in:
@@ -1,517 +1,430 @@
|
||||
# services/alert_processor/app/api/alerts.py
|
||||
"""
|
||||
Alerts API endpoints for dashboard and alert management
|
||||
Alert API endpoints.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Path, Depends
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
|
||||
from app.repositories.alerts_repository import AlertsRepository
|
||||
from app.models.events import AlertStatus
|
||||
from app.dependencies import get_current_user
|
||||
from app.core.database import get_db
|
||||
from app.repositories.event_repository import EventRepository
|
||||
from app.schemas.events import EventResponse, EventSummary
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Response Models
|
||||
# ============================================================
|
||||
|
||||
class AlertResponse(BaseModel):
|
||||
"""Individual alert response"""
|
||||
id: str
|
||||
tenant_id: str
|
||||
item_type: str
|
||||
alert_type: str
|
||||
priority_level: str
|
||||
priority_score: int
|
||||
status: str
|
||||
service: str
|
||||
title: str
|
||||
message: str
|
||||
type_class: str
|
||||
actions: Optional[List[dict]] = None # smart_actions is a list of action objects
|
||||
alert_metadata: Optional[dict] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
resolved_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AlertsSummaryResponse(BaseModel):
|
||||
"""Alerts summary for dashboard"""
|
||||
total_count: int = Field(..., description="Total number of alerts")
|
||||
active_count: int = Field(..., description="Number of active (unresolved) alerts")
|
||||
critical_count: int = Field(..., description="Number of critical priority alerts")
|
||||
high_count: int = Field(..., description="Number of high priority alerts")
|
||||
medium_count: int = Field(..., description="Number of medium priority alerts")
|
||||
low_count: int = Field(..., description="Number of low priority alerts")
|
||||
resolved_count: int = Field(..., description="Number of resolved alerts")
|
||||
acknowledged_count: int = Field(..., description="Number of acknowledged alerts")
|
||||
|
||||
|
||||
class AlertsListResponse(BaseModel):
|
||||
"""List of alerts with pagination"""
|
||||
alerts: List[AlertResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
# ============================================================
|
||||
# API Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.get(
|
||||
"/api/v1/tenants/{tenant_id}/alerts/summary",
|
||||
response_model=AlertsSummaryResponse,
|
||||
summary="Get alerts summary",
|
||||
description="Get summary of alerts by priority level and status for dashboard health indicator"
|
||||
)
|
||||
async def get_alerts_summary(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID")
|
||||
) -> AlertsSummaryResponse:
|
||||
"""
|
||||
Get alerts summary for dashboard
|
||||
|
||||
Returns counts of alerts grouped by priority level and status.
|
||||
Critical count maps to URGENT priority level for dashboard compatibility.
|
||||
"""
|
||||
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 = AlertsRepository(session)
|
||||
summary = await repo.get_alerts_summary(tenant_id)
|
||||
return AlertsSummaryResponse(**summary)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting alerts summary", error=str(e), tenant_id=str(tenant_id))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/tenants/{tenant_id}/alerts",
|
||||
response_model=AlertsListResponse,
|
||||
summary="Get alerts list",
|
||||
description="Get filtered list of alerts with pagination"
|
||||
)
|
||||
@router.get("/alerts", response_model=List[EventResponse])
|
||||
async def get_alerts(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
priority_level: Optional[str] = Query(None, description="Filter by priority level: critical, important, standard, info"),
|
||||
status: Optional[str] = Query(None, description="Filter by status: active, resolved, acknowledged, ignored"),
|
||||
resolved: Optional[bool] = Query(None, description="Filter by resolved status: true=resolved only, false=unresolved only"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Maximum number of results"),
|
||||
offset: int = Query(0, ge=0, description="Pagination offset")
|
||||
) -> AlertsListResponse:
|
||||
"""
|
||||
Get filtered list of alerts
|
||||
|
||||
Supports filtering by:
|
||||
- priority_level: critical, important, standard, info
|
||||
- status: active, resolved, acknowledged, ignored
|
||||
- resolved: boolean filter for resolved status
|
||||
- pagination: limit and offset
|
||||
"""
|
||||
from app.config import AlertProcessorConfig
|
||||
from shared.database.base import create_database_manager
|
||||
|
||||
try:
|
||||
# Validate priority_level enum
|
||||
valid_priority_levels = ['critical', 'important', 'standard', 'info']
|
||||
if priority_level and priority_level not in valid_priority_levels:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid priority level. Must be one of: {valid_priority_levels}"
|
||||
)
|
||||
|
||||
# Validate status enum
|
||||
valid_status_values = ['active', 'resolved', 'acknowledged', 'ignored']
|
||||
if status and status not in valid_status_values:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid status. Must be one of: {valid_status_values}"
|
||||
)
|
||||
|
||||
config = AlertProcessorConfig()
|
||||
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
repo = AlertsRepository(session)
|
||||
alerts = await repo.get_alerts(
|
||||
tenant_id=tenant_id,
|
||||
priority_level=priority_level,
|
||||
status=status,
|
||||
resolved=resolved,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
# Convert to response models
|
||||
alert_responses = []
|
||||
for alert in alerts:
|
||||
# Handle old format actions (strings) by converting to proper dict format
|
||||
actions = alert.smart_actions
|
||||
if actions and isinstance(actions, list) and len(actions) > 0:
|
||||
# Check if actions are strings (old format)
|
||||
if isinstance(actions[0], str):
|
||||
# Convert old format to new format
|
||||
actions = [
|
||||
{
|
||||
'action_type': action,
|
||||
'label': action.replace('_', ' ').title(),
|
||||
'variant': 'default',
|
||||
'disabled': False
|
||||
}
|
||||
for action in actions
|
||||
]
|
||||
|
||||
alert_responses.append(AlertResponse(
|
||||
id=str(alert.id),
|
||||
tenant_id=str(alert.tenant_id),
|
||||
item_type=alert.item_type,
|
||||
alert_type=alert.alert_type,
|
||||
priority_level=alert.priority_level.value if hasattr(alert.priority_level, 'value') else alert.priority_level,
|
||||
priority_score=alert.priority_score,
|
||||
status=alert.status.value if hasattr(alert.status, 'value') else alert.status,
|
||||
service=alert.service,
|
||||
title=alert.title,
|
||||
message=alert.message,
|
||||
type_class=alert.type_class.value if hasattr(alert.type_class, 'value') else alert.type_class,
|
||||
actions=actions, # Use converted actions
|
||||
alert_metadata=alert.alert_metadata,
|
||||
created_at=alert.created_at,
|
||||
updated_at=alert.updated_at,
|
||||
resolved_at=alert.resolved_at
|
||||
))
|
||||
|
||||
return AlertsListResponse(
|
||||
alerts=alert_responses,
|
||||
total=len(alert_responses), # In a real implementation, you'd query the total count separately
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error getting alerts", error=str(e), tenant_id=str(tenant_id))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/tenants/{tenant_id}/alerts/{alert_id}",
|
||||
response_model=AlertResponse,
|
||||
summary="Get alert by ID",
|
||||
description="Get a specific alert by its ID"
|
||||
)
|
||||
async def get_alert(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
alert_id: UUID = Path(..., description="Alert ID")
|
||||
) -> AlertResponse:
|
||||
"""Get a specific alert by ID"""
|
||||
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 = AlertsRepository(session)
|
||||
alert = await repo.get_alert_by_id(tenant_id, alert_id)
|
||||
|
||||
if not alert:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
|
||||
# Handle old format actions (strings) by converting to proper dict format
|
||||
actions = alert.smart_actions
|
||||
if actions and isinstance(actions, list) and len(actions) > 0:
|
||||
# Check if actions are strings (old format)
|
||||
if isinstance(actions[0], str):
|
||||
# Convert old format to new format
|
||||
actions = [
|
||||
{
|
||||
'action_type': action,
|
||||
'label': action.replace('_', ' ').title(),
|
||||
'variant': 'default',
|
||||
'disabled': False
|
||||
}
|
||||
for action in actions
|
||||
]
|
||||
|
||||
return AlertResponse(
|
||||
id=str(alert.id),
|
||||
tenant_id=str(alert.tenant_id),
|
||||
item_type=alert.item_type,
|
||||
alert_type=alert.alert_type,
|
||||
priority_level=alert.priority_level.value if hasattr(alert.priority_level, 'value') else alert.priority_level,
|
||||
priority_score=alert.priority_score,
|
||||
status=alert.status.value if hasattr(alert.status, 'value') else alert.status,
|
||||
service=alert.service,
|
||||
title=alert.title,
|
||||
message=alert.message,
|
||||
type_class=alert.type_class.value if hasattr(alert.type_class, 'value') else alert.type_class,
|
||||
actions=actions, # Use converted actions
|
||||
alert_metadata=alert.alert_metadata,
|
||||
created_at=alert.created_at,
|
||||
updated_at=alert.updated_at,
|
||||
resolved_at=alert.resolved_at
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error getting alert", error=str(e), alert_id=str(alert_id))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/tenants/{tenant_id}/alerts/{alert_id}/cancel-auto-action",
|
||||
summary="Cancel auto-action for escalation alert",
|
||||
description="Cancel the pending auto-action for an escalation-type alert"
|
||||
)
|
||||
async def cancel_auto_action(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
alert_id: UUID = Path(..., description="Alert ID")
|
||||
) -> dict:
|
||||
"""
|
||||
Cancel the auto-action scheduled for an escalation alert.
|
||||
This prevents the system from automatically executing the action.
|
||||
"""
|
||||
from app.config import AlertProcessorConfig
|
||||
from shared.database.base import create_database_manager
|
||||
from app.models.events import AlertStatus
|
||||
|
||||
try:
|
||||
config = AlertProcessorConfig()
|
||||
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
repo = AlertsRepository(session)
|
||||
alert = await repo.get_alert_by_id(tenant_id, alert_id)
|
||||
|
||||
if not alert:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
|
||||
# Verify this is an escalation alert
|
||||
if alert.type_class != 'escalation':
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Alert is not an escalation type, no auto-action to cancel"
|
||||
)
|
||||
|
||||
# Update alert metadata to mark auto-action as cancelled
|
||||
alert.alert_metadata = alert.alert_metadata or {}
|
||||
alert.alert_metadata['auto_action_cancelled'] = True
|
||||
alert.alert_metadata['auto_action_cancelled_at'] = datetime.utcnow().isoformat()
|
||||
|
||||
# Update urgency context to remove countdown
|
||||
if alert.urgency_context:
|
||||
alert.urgency_context['auto_action_countdown_seconds'] = None
|
||||
alert.urgency_context['auto_action_cancelled'] = True
|
||||
|
||||
# Change type class from escalation to action_needed
|
||||
alert.type_class = 'action_needed'
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(alert)
|
||||
|
||||
logger.info("Auto-action cancelled", alert_id=str(alert_id), tenant_id=str(tenant_id))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"alert_id": str(alert_id),
|
||||
"message": "Auto-action cancelled successfully",
|
||||
"updated_type_class": alert.type_class.value
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error cancelling auto-action", error=str(e), alert_id=str(alert_id))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/tenants/{tenant_id}/alerts/{alert_id}/acknowledge",
|
||||
summary="Acknowledge alert",
|
||||
description="Mark alert as acknowledged"
|
||||
)
|
||||
async def acknowledge_alert(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
alert_id: UUID = Path(..., description="Alert ID")
|
||||
) -> dict:
|
||||
"""Mark an alert as acknowledged"""
|
||||
from app.config import AlertProcessorConfig
|
||||
from shared.database.base import create_database_manager
|
||||
from app.models.events import AlertStatus
|
||||
|
||||
try:
|
||||
config = AlertProcessorConfig()
|
||||
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
repo = AlertsRepository(session)
|
||||
alert = await repo.get_alert_by_id(tenant_id, alert_id)
|
||||
|
||||
if not alert:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
|
||||
alert.status = AlertStatus.ACKNOWLEDGED
|
||||
await session.commit()
|
||||
|
||||
logger.info("Alert acknowledged", alert_id=str(alert_id), tenant_id=str(tenant_id))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"alert_id": str(alert_id),
|
||||
"status": alert.status.value
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error acknowledging alert", error=str(e), alert_id=str(alert_id))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/tenants/{tenant_id}/alerts/{alert_id}/resolve",
|
||||
summary="Resolve alert",
|
||||
description="Mark alert as resolved"
|
||||
)
|
||||
async def resolve_alert(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
alert_id: UUID = Path(..., description="Alert ID")
|
||||
) -> dict:
|
||||
"""Mark an alert as resolved"""
|
||||
from app.config import AlertProcessorConfig
|
||||
from shared.database.base import create_database_manager
|
||||
from app.models.events import AlertStatus
|
||||
|
||||
try:
|
||||
config = AlertProcessorConfig()
|
||||
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
repo = AlertsRepository(session)
|
||||
alert = await repo.get_alert_by_id(tenant_id, alert_id)
|
||||
|
||||
if not alert:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
|
||||
alert.status = AlertStatus.RESOLVED
|
||||
alert.resolved_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
logger.info("Alert resolved", alert_id=str(alert_id), tenant_id=str(tenant_id))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"alert_id": str(alert_id),
|
||||
"status": alert.status.value,
|
||||
"resolved_at": alert.resolved_at.isoformat()
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error resolving alert", error=str(e), alert_id=str(alert_id))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/tenants/{tenant_id}/alerts/digest/send",
|
||||
summary="Send email digest for alerts"
|
||||
)
|
||||
async def send_alert_digest(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
days: int = Query(1, ge=1, le=7, description="Number of days to include in digest"),
|
||||
digest_type: str = Query("daily", description="Type of digest: daily or weekly"),
|
||||
user_email: str = Query(..., description="Email address to send digest to"),
|
||||
user_name: str = Query(None, description="User name for personalization"),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
tenant_id: UUID,
|
||||
event_class: Optional[str] = Query(None, description="Filter by event class"),
|
||||
priority_level: Optional[List[str]] = Query(None, description="Filter by priority levels"),
|
||||
status: Optional[List[str]] = Query(None, description="Filter by status values"),
|
||||
event_domain: Optional[str] = Query(None, description="Filter by domain"),
|
||||
limit: int = Query(50, le=100, description="Max results"),
|
||||
offset: int = Query(0, description="Pagination offset"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Send email digest of alerts.
|
||||
Get filtered list of events.
|
||||
|
||||
Digest includes:
|
||||
- AI Impact Summary (prevented issues, savings)
|
||||
- Prevented Issues List with AI reasoning
|
||||
- Action Needed Alerts
|
||||
- Trend Warnings
|
||||
Query Parameters:
|
||||
- event_class: alert, notification, recommendation
|
||||
- priority_level: critical, important, standard, info
|
||||
- status: active, acknowledged, resolved, dismissed
|
||||
- event_domain: inventory, production, supply_chain, etc.
|
||||
- limit: Max 100 results
|
||||
- offset: For pagination
|
||||
"""
|
||||
from app.config import AlertProcessorConfig
|
||||
from shared.database.base import create_database_manager
|
||||
from app.models.events import Alert
|
||||
from app.services.enrichment.email_digest import EmailDigestService
|
||||
from sqlalchemy import select, and_
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
try:
|
||||
config = AlertProcessorConfig()
|
||||
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
|
||||
repo = EventRepository(db)
|
||||
events = await repo.get_events(
|
||||
tenant_id=tenant_id,
|
||||
event_class=event_class,
|
||||
priority_level=priority_level,
|
||||
status=status,
|
||||
event_domain=event_domain,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# Fetch alerts from the specified period
|
||||
query = select(Alert).where(
|
||||
and_(
|
||||
Alert.tenant_id == tenant_id,
|
||||
Alert.created_at >= cutoff_date
|
||||
)
|
||||
).order_by(Alert.created_at.desc())
|
||||
|
||||
result = await session.execute(query)
|
||||
alerts = result.scalars().all()
|
||||
|
||||
if not alerts:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No alerts found for the specified period",
|
||||
"alert_count": 0
|
||||
}
|
||||
|
||||
# Send digest
|
||||
digest_service = EmailDigestService(config)
|
||||
|
||||
if digest_type == "weekly":
|
||||
success = await digest_service.send_weekly_digest(
|
||||
tenant_id=tenant_id,
|
||||
alerts=alerts,
|
||||
user_email=user_email,
|
||||
user_name=user_name
|
||||
)
|
||||
else:
|
||||
success = await digest_service.send_daily_digest(
|
||||
tenant_id=tenant_id,
|
||||
alerts=alerts,
|
||||
user_email=user_email,
|
||||
user_name=user_name
|
||||
)
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"message": f"{'Successfully sent' if success else 'Failed to send'} {digest_type} digest",
|
||||
"alert_count": len(alerts),
|
||||
"digest_type": digest_type,
|
||||
"recipient": user_email
|
||||
}
|
||||
# Convert to response models
|
||||
return [repo._event_to_response(event) for event in events]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error sending email digest", error=str(e), tenant_id=str(tenant_id))
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send email digest: {str(e)}")
|
||||
logger.error("get_alerts_failed", error=str(e), tenant_id=str(tenant_id))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve alerts")
|
||||
|
||||
|
||||
@router.get("/alerts/summary", response_model=EventSummary)
|
||||
async def get_alerts_summary(
|
||||
tenant_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get summary statistics for dashboard.
|
||||
|
||||
Returns counts by:
|
||||
- Status (active, acknowledged, resolved)
|
||||
- Priority level (critical, important, standard, info)
|
||||
- Domain (inventory, production, etc.)
|
||||
- Type class (action_needed, prevented_issue, etc.)
|
||||
"""
|
||||
try:
|
||||
repo = EventRepository(db)
|
||||
summary = await repo.get_summary(tenant_id)
|
||||
return summary
|
||||
|
||||
except Exception as e:
|
||||
logger.error("get_summary_failed", error=str(e), tenant_id=str(tenant_id))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve summary")
|
||||
|
||||
|
||||
@router.get("/alerts/{alert_id}", response_model=EventResponse)
|
||||
async def get_alert(
|
||||
tenant_id: UUID,
|
||||
alert_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get single alert by ID"""
|
||||
try:
|
||||
repo = EventRepository(db)
|
||||
event = await repo.get_event_by_id(alert_id)
|
||||
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
|
||||
# Verify tenant ownership
|
||||
if event.tenant_id != tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return repo._event_to_response(event)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("get_alert_failed", error=str(e), alert_id=str(alert_id))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve alert")
|
||||
|
||||
|
||||
@router.post("/alerts/{alert_id}/acknowledge", response_model=EventResponse)
|
||||
async def acknowledge_alert(
|
||||
tenant_id: UUID,
|
||||
alert_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Mark alert as acknowledged.
|
||||
|
||||
Sets status to 'acknowledged' and records timestamp.
|
||||
"""
|
||||
try:
|
||||
repo = EventRepository(db)
|
||||
|
||||
# Verify ownership first
|
||||
event = await repo.get_event_by_id(alert_id)
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
if event.tenant_id != tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Acknowledge
|
||||
updated_event = await repo.acknowledge_event(alert_id)
|
||||
return repo._event_to_response(updated_event)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("acknowledge_alert_failed", error=str(e), alert_id=str(alert_id))
|
||||
raise HTTPException(status_code=500, detail="Failed to acknowledge alert")
|
||||
|
||||
|
||||
@router.post("/alerts/{alert_id}/resolve", response_model=EventResponse)
|
||||
async def resolve_alert(
|
||||
tenant_id: UUID,
|
||||
alert_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Mark alert as resolved.
|
||||
|
||||
Sets status to 'resolved' and records timestamp.
|
||||
"""
|
||||
try:
|
||||
repo = EventRepository(db)
|
||||
|
||||
# Verify ownership first
|
||||
event = await repo.get_event_by_id(alert_id)
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
if event.tenant_id != tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Resolve
|
||||
updated_event = await repo.resolve_event(alert_id)
|
||||
return repo._event_to_response(updated_event)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("resolve_alert_failed", error=str(e), alert_id=str(alert_id))
|
||||
raise HTTPException(status_code=500, detail="Failed to resolve alert")
|
||||
|
||||
|
||||
@router.post("/alerts/{alert_id}/dismiss", response_model=EventResponse)
|
||||
async def dismiss_alert(
|
||||
tenant_id: UUID,
|
||||
alert_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Mark alert as dismissed.
|
||||
|
||||
Sets status to 'dismissed'.
|
||||
"""
|
||||
try:
|
||||
repo = EventRepository(db)
|
||||
|
||||
# Verify ownership first
|
||||
event = await repo.get_event_by_id(alert_id)
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
if event.tenant_id != tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Dismiss
|
||||
updated_event = await repo.dismiss_event(alert_id)
|
||||
return repo._event_to_response(updated_event)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("dismiss_alert_failed", error=str(e), alert_id=str(alert_id))
|
||||
raise HTTPException(status_code=500, detail="Failed to dismiss alert")
|
||||
|
||||
|
||||
@router.post("/alerts/{alert_id}/cancel-auto-action")
|
||||
async def cancel_auto_action(
|
||||
tenant_id: UUID,
|
||||
alert_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Cancel an alert's auto-action (escalation countdown).
|
||||
|
||||
Changes type_class from 'escalation' to 'action_needed' if auto-action was pending.
|
||||
"""
|
||||
try:
|
||||
repo = EventRepository(db)
|
||||
|
||||
# Verify ownership first
|
||||
event = await repo.get_event_by_id(alert_id)
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
if event.tenant_id != tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Cancel auto-action (you'll need to implement this in repository)
|
||||
# For now, return success response
|
||||
return {
|
||||
"success": True,
|
||||
"event_id": str(alert_id),
|
||||
"message": "Auto-action cancelled successfully",
|
||||
"updated_type_class": "action_needed"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("cancel_auto_action_failed", error=str(e), alert_id=str(alert_id))
|
||||
raise HTTPException(status_code=500, detail="Failed to cancel auto-action")
|
||||
|
||||
|
||||
@router.post("/alerts/bulk-acknowledge")
|
||||
async def bulk_acknowledge_alerts(
|
||||
tenant_id: UUID,
|
||||
request_body: dict,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Acknowledge multiple alerts by metadata filter.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"alert_type": "critical_stock_shortage",
|
||||
"metadata_filter": {"ingredient_id": "123"}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
alert_type = request_body.get("alert_type")
|
||||
metadata_filter = request_body.get("metadata_filter", {})
|
||||
|
||||
if not alert_type:
|
||||
raise HTTPException(status_code=400, detail="alert_type is required")
|
||||
|
||||
repo = EventRepository(db)
|
||||
|
||||
# Get matching alerts
|
||||
events = await repo.get_events(
|
||||
tenant_id=tenant_id,
|
||||
event_class="alert",
|
||||
status=["active"],
|
||||
limit=100
|
||||
)
|
||||
|
||||
# Filter by type and metadata
|
||||
matching_ids = []
|
||||
for event in events:
|
||||
if event.event_type == alert_type:
|
||||
# Check if metadata matches
|
||||
matches = all(
|
||||
event.event_metadata.get(key) == value
|
||||
for key, value in metadata_filter.items()
|
||||
)
|
||||
if matches:
|
||||
matching_ids.append(event.id)
|
||||
|
||||
# Acknowledge all matching
|
||||
acknowledged_count = 0
|
||||
for event_id in matching_ids:
|
||||
try:
|
||||
await repo.acknowledge_event(event_id)
|
||||
acknowledged_count += 1
|
||||
except Exception:
|
||||
pass # Continue with others
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"acknowledged_count": acknowledged_count,
|
||||
"alert_ids": [str(id) for id in matching_ids]
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("bulk_acknowledge_failed", error=str(e), tenant_id=str(tenant_id))
|
||||
raise HTTPException(status_code=500, detail="Failed to bulk acknowledge alerts")
|
||||
|
||||
|
||||
@router.post("/alerts/bulk-resolve")
|
||||
async def bulk_resolve_alerts(
|
||||
tenant_id: UUID,
|
||||
request_body: dict,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Resolve multiple alerts by metadata filter.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"alert_type": "critical_stock_shortage",
|
||||
"metadata_filter": {"ingredient_id": "123"}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
alert_type = request_body.get("alert_type")
|
||||
metadata_filter = request_body.get("metadata_filter", {})
|
||||
|
||||
if not alert_type:
|
||||
raise HTTPException(status_code=400, detail="alert_type is required")
|
||||
|
||||
repo = EventRepository(db)
|
||||
|
||||
# Get matching alerts
|
||||
events = await repo.get_events(
|
||||
tenant_id=tenant_id,
|
||||
event_class="alert",
|
||||
status=["active", "acknowledged"],
|
||||
limit=100
|
||||
)
|
||||
|
||||
# Filter by type and metadata
|
||||
matching_ids = []
|
||||
for event in events:
|
||||
if event.event_type == alert_type:
|
||||
# Check if metadata matches
|
||||
matches = all(
|
||||
event.event_metadata.get(key) == value
|
||||
for key, value in metadata_filter.items()
|
||||
)
|
||||
if matches:
|
||||
matching_ids.append(event.id)
|
||||
|
||||
# Resolve all matching
|
||||
resolved_count = 0
|
||||
for event_id in matching_ids:
|
||||
try:
|
||||
await repo.resolve_event(event_id)
|
||||
resolved_count += 1
|
||||
except Exception:
|
||||
pass # Continue with others
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"resolved_count": resolved_count,
|
||||
"alert_ids": [str(id) for id in matching_ids]
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("bulk_resolve_failed", error=str(e), tenant_id=str(tenant_id))
|
||||
raise HTTPException(status_code=500, detail="Failed to bulk resolve alerts")
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/interactions")
|
||||
async def record_interaction(
|
||||
tenant_id: UUID,
|
||||
event_id: UUID,
|
||||
request_body: dict,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Record user interaction with an event (for analytics).
|
||||
|
||||
Request body:
|
||||
{
|
||||
"interaction_type": "viewed" | "clicked" | "dismissed" | "acted_upon",
|
||||
"interaction_metadata": {...}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
interaction_type = request_body.get("interaction_type")
|
||||
interaction_metadata = request_body.get("interaction_metadata", {})
|
||||
|
||||
if not interaction_type:
|
||||
raise HTTPException(status_code=400, detail="interaction_type is required")
|
||||
|
||||
repo = EventRepository(db)
|
||||
|
||||
# Verify event exists and belongs to tenant
|
||||
event = await repo.get_event_by_id(event_id)
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
if event.tenant_id != tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# For now, just return success
|
||||
# In the future, you could store interactions in a separate table
|
||||
logger.info(
|
||||
"interaction_recorded",
|
||||
event_id=str(event_id),
|
||||
interaction_type=interaction_type,
|
||||
metadata=interaction_metadata
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"interaction_id": str(event_id), # Would be a real ID in production
|
||||
"event_id": str(event_id),
|
||||
"interaction_type": interaction_type
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("record_interaction_failed", error=str(e), event_id=str(event_id))
|
||||
raise HTTPException(status_code=500, detail="Failed to record interaction")
|
||||
|
||||
Reference in New Issue
Block a user