New alert system and panel de control page

This commit is contained in:
Urtzi Alfaro
2025-11-27 15:52:40 +01:00
parent 1a2f4602f3
commit e902419b6e
178 changed files with 20982 additions and 6944 deletions

View File

@@ -0,0 +1,402 @@
"""
Unified Event Storage Models
This module defines separate storage models for:
- Alerts: Full enrichment, lifecycle tracking
- Notifications: Lightweight, ephemeral (7-day TTL)
- Recommendations: Medium weight, dismissible
Replaces the old single Alert model with semantic clarity.
"""
from sqlalchemy import Column, String, Text, DateTime, Integer, ForeignKey, Float, CheckConstraint, Index, Boolean, Enum
from sqlalchemy.dialects.postgresql import UUID, JSONB
from datetime import datetime, timezone, timedelta
from typing import Dict, Any, Optional
import uuid
import enum
from shared.database.base import Base
def utc_now():
"""Return current UTC time as timezone-aware datetime"""
return datetime.now(timezone.utc)
# ============================================================
# ENUMS
# ============================================================
class AlertStatus(enum.Enum):
"""Alert lifecycle status"""
ACTIVE = "active"
RESOLVED = "resolved"
ACKNOWLEDGED = "acknowledged"
IN_PROGRESS = "in_progress"
DISMISSED = "dismissed"
IGNORED = "ignored"
class PriorityLevel(enum.Enum):
"""Priority levels based on multi-factor scoring"""
CRITICAL = "critical" # 90-100
IMPORTANT = "important" # 70-89
STANDARD = "standard" # 50-69
INFO = "info" # 0-49
class AlertTypeClass(enum.Enum):
"""Alert type classification (for alerts only)"""
ACTION_NEEDED = "action_needed" # Requires user action
PREVENTED_ISSUE = "prevented_issue" # AI already handled
TREND_WARNING = "trend_warning" # Pattern detected
ESCALATION = "escalation" # Time-sensitive with countdown
INFORMATION = "information" # FYI only
class NotificationType(enum.Enum):
"""Notification type classification"""
STATE_CHANGE = "state_change"
COMPLETION = "completion"
ARRIVAL = "arrival"
DEPARTURE = "departure"
UPDATE = "update"
SYSTEM_EVENT = "system_event"
class RecommendationType(enum.Enum):
"""Recommendation type classification"""
OPTIMIZATION = "optimization"
COST_REDUCTION = "cost_reduction"
RISK_MITIGATION = "risk_mitigation"
TREND_INSIGHT = "trend_insight"
BEST_PRACTICE = "best_practice"
# ============================================================
# ALERT MODEL (Full Enrichment)
# ============================================================
class Alert(Base):
"""
Alert model with full enrichment capabilities.
Used for EventClass.ALERT only.
Full priority scoring, context enrichment, smart actions, lifecycle tracking.
"""
__tablename__ = "alerts"
# Primary key
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Event classification
item_type = Column(String(50), nullable=False) # 'alert' or 'recommendation' - from old schema
event_domain = Column(String(50), nullable=True, index=True) # inventory, production, etc. - new field, make nullable for now
alert_type = Column(String(100), nullable=False) # specific type of alert (e.g., 'low_stock', 'supplier_delay') - from old schema
service = Column(String(100), nullable=False)
# Content
title = Column(String(500), nullable=False)
message = Column(Text, nullable=False)
# Alert-specific classification
type_class = Column(
Enum(AlertTypeClass, name='alerttypeclass', create_type=False, native_enum=True, values_callable=lambda x: [e.value for e in x]),
nullable=False,
index=True
)
# Status
status = Column(
Enum(AlertStatus, name='alertstatus', create_type=False, native_enum=True, values_callable=lambda x: [e.value for e in x]),
default=AlertStatus.ACTIVE,
nullable=False,
index=True
)
# Priority (multi-factor scored)
priority_score = Column(Integer, nullable=False) # 0-100
priority_level = Column(
Enum(PriorityLevel, name='prioritylevel', create_type=False, native_enum=True, values_callable=lambda x: [e.value for e in x]),
nullable=False,
index=True
)
# Enrichment context (JSONB)
orchestrator_context = Column(JSONB, nullable=True)
business_impact = Column(JSONB, nullable=True)
urgency_context = Column(JSONB, nullable=True)
user_agency = Column(JSONB, nullable=True)
trend_context = Column(JSONB, nullable=True)
# Smart actions
smart_actions = Column(JSONB, nullable=False)
# AI reasoning
ai_reasoning_summary = Column(Text, nullable=True)
confidence_score = Column(Float, nullable=False, default=0.8)
# Timing intelligence
timing_decision = Column(String(50), nullable=False, default='send_now')
scheduled_send_time = Column(DateTime(timezone=True), nullable=True)
# Placement hints
placement = Column(JSONB, nullable=False)
# Escalation & chaining
action_created_at = Column(DateTime(timezone=True), nullable=True, index=True)
superseded_by_action_id = Column(UUID(as_uuid=True), nullable=True, index=True)
hidden_from_ui = Column(Boolean, default=False, nullable=False, index=True)
# Metadata
alert_metadata = Column(JSONB, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False, index=True)
updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
resolved_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index('idx_alerts_tenant_status', 'tenant_id', 'status'),
Index('idx_alerts_priority_score', 'tenant_id', 'priority_score', 'created_at'),
Index('idx_alerts_type_class', 'tenant_id', 'type_class', 'status'),
Index('idx_alerts_domain', 'tenant_id', 'event_domain', 'status'),
Index('idx_alerts_timing', 'timing_decision', 'scheduled_send_time'),
CheckConstraint('priority_score >= 0 AND priority_score <= 100', name='chk_alert_priority_range'),
)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for API/SSE"""
return {
'id': str(self.id),
'tenant_id': str(self.tenant_id),
'event_class': 'alert',
'event_domain': self.event_domain,
'event_type': self.alert_type,
'alert_type': self.alert_type, # Frontend expects this field name
'service': self.service,
'title': self.title,
'message': self.message,
'type_class': self.type_class.value if isinstance(self.type_class, AlertTypeClass) else self.type_class,
'status': self.status.value if isinstance(self.status, AlertStatus) else self.status,
'priority_level': self.priority_level.value if isinstance(self.priority_level, PriorityLevel) else self.priority_level,
'priority_score': self.priority_score,
'orchestrator_context': self.orchestrator_context,
'business_impact': self.business_impact,
'urgency_context': self.urgency_context,
'user_agency': self.user_agency,
'trend_context': self.trend_context,
'actions': self.smart_actions,
'ai_reasoning_summary': self.ai_reasoning_summary,
'confidence_score': self.confidence_score,
'timing_decision': self.timing_decision,
'scheduled_send_time': self.scheduled_send_time.isoformat() if self.scheduled_send_time else None,
'placement': self.placement,
'action_created_at': self.action_created_at.isoformat() if self.action_created_at else None,
'superseded_by_action_id': str(self.superseded_by_action_id) if self.superseded_by_action_id else None,
'hidden_from_ui': self.hidden_from_ui,
'alert_metadata': self.alert_metadata, # Frontend expects alert_metadata
'metadata': self.alert_metadata, # Keep legacy field for backwards compat
'timestamp': self.created_at.isoformat() if self.created_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'resolved_at': self.resolved_at.isoformat() if self.resolved_at else None,
}
# ============================================================
# NOTIFICATION MODEL (Lightweight, Ephemeral)
# ============================================================
class Notification(Base):
"""
Notification model for informational state changes.
Used for EventClass.NOTIFICATION only.
Lightweight schema, no priority scoring, no lifecycle, 7-day TTL.
"""
__tablename__ = "notifications"
# Primary key
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Event classification
event_domain = Column(String(50), nullable=False, index=True)
event_type = Column(String(100), nullable=False)
notification_type = Column(String(50), nullable=False) # NotificationType
service = Column(String(100), nullable=False)
# Content
title = Column(String(500), nullable=False)
message = Column(Text, nullable=False)
# Entity context (optional)
entity_type = Column(String(100), nullable=True) # 'batch', 'delivery', 'po', etc.
entity_id = Column(String(100), nullable=True, index=True)
old_state = Column(String(100), nullable=True)
new_state = Column(String(100), nullable=True)
# Display metadata
notification_metadata = Column(JSONB, nullable=True)
# Placement hints (lightweight)
placement = Column(JSONB, nullable=False, default=['notification_panel'])
# TTL tracking
expires_at = Column(DateTime(timezone=True), nullable=False, index=True)
# Timestamps
created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False, index=True)
__table_args__ = (
Index('idx_notifications_tenant_domain', 'tenant_id', 'event_domain', 'created_at'),
Index('idx_notifications_entity', 'tenant_id', 'entity_type', 'entity_id'),
Index('idx_notifications_expiry', 'expires_at'),
)
def __init__(self, **kwargs):
"""Set default expiry to 7 days from now"""
if 'expires_at' not in kwargs:
kwargs['expires_at'] = utc_now() + timedelta(days=7)
super().__init__(**kwargs)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for API/SSE"""
return {
'id': str(self.id),
'tenant_id': str(self.tenant_id),
'event_class': 'notification',
'event_domain': self.event_domain,
'event_type': self.event_type,
'notification_type': self.notification_type,
'service': self.service,
'title': self.title,
'message': self.message,
'entity_type': self.entity_type,
'entity_id': self.entity_id,
'old_state': self.old_state,
'new_state': self.new_state,
'metadata': self.notification_metadata,
'placement': self.placement,
'timestamp': self.created_at.isoformat() if self.created_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
}
# ============================================================
# RECOMMENDATION MODEL (Medium Weight, Dismissible)
# ============================================================
class Recommendation(Base):
"""
Recommendation model for AI-generated suggestions.
Used for EventClass.RECOMMENDATION only.
Medium weight schema, light priority, no orchestrator queries, dismissible.
"""
__tablename__ = "recommendations"
# Primary key
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Event classification
event_domain = Column(String(50), nullable=False, index=True)
event_type = Column(String(100), nullable=False)
recommendation_type = Column(String(50), nullable=False) # RecommendationType
service = Column(String(100), nullable=False)
# Content
title = Column(String(500), nullable=False)
message = Column(Text, nullable=False)
# Light priority (info by default)
priority_level = Column(String(50), nullable=False, default='info')
# Context (lighter than alerts)
estimated_impact = Column(JSONB, nullable=True)
suggested_actions = Column(JSONB, nullable=True)
# AI reasoning
ai_reasoning_summary = Column(Text, nullable=True)
confidence_score = Column(Float, nullable=True)
# Dismissal tracking
dismissed_at = Column(DateTime(timezone=True), nullable=True, index=True)
dismissed_by = Column(UUID(as_uuid=True), nullable=True)
# Metadata
recommendation_metadata = Column(JSONB, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False, index=True)
updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
__table_args__ = (
Index('idx_recommendations_tenant_domain', 'tenant_id', 'event_domain', 'created_at'),
Index('idx_recommendations_dismissed', 'tenant_id', 'dismissed_at'),
)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for API/SSE"""
return {
'id': str(self.id),
'tenant_id': str(self.tenant_id),
'event_class': 'recommendation',
'event_domain': self.event_domain,
'event_type': self.event_type,
'recommendation_type': self.recommendation_type,
'service': self.service,
'title': self.title,
'message': self.message,
'priority_level': self.priority_level,
'estimated_impact': self.estimated_impact,
'suggested_actions': self.suggested_actions,
'ai_reasoning_summary': self.ai_reasoning_summary,
'confidence_score': self.confidence_score,
'dismissed_at': self.dismissed_at.isoformat() if self.dismissed_at else None,
'dismissed_by': str(self.dismissed_by) if self.dismissed_by else None,
'metadata': self.recommendation_metadata,
'timestamp': self.created_at.isoformat() if self.created_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
# ============================================================
# INTERACTION TRACKING (Shared across all event types)
# ============================================================
class EventInteraction(Base):
"""Event interaction tracking for analytics"""
__tablename__ = "event_interactions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Event reference (polymorphic)
event_id = Column(UUID(as_uuid=True), nullable=False, index=True)
event_class = Column(String(50), nullable=False, index=True) # 'alert', 'notification', 'recommendation'
# User
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Interaction details
interaction_type = Column(String(50), nullable=False, index=True) # acknowledged, resolved, dismissed, clicked, etc.
interacted_at = Column(DateTime(timezone=True), nullable=False, default=utc_now, index=True)
response_time_seconds = Column(Integer, nullable=True)
# Context
interaction_metadata = Column(JSONB, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), nullable=False, default=utc_now)
__table_args__ = (
Index('idx_event_interactions_event', 'event_id', 'event_class'),
Index('idx_event_interactions_user', 'tenant_id', 'user_id', 'interacted_at'),
)