403 lines
16 KiB
Python
403 lines
16 KiB
Python
|
|
"""
|
||
|
|
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'),
|
||
|
|
)
|