Files
2026-03-19 16:58:23 +01:00

212 lines
6.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Modelli Notifiche Multi-canale.
Struttura:
NotificationChannel → configurazione canale (Webhook, Email, Telegram, WhatsApp)
NotificationRule → regola evento → canale
NotificationLog → log tentativi di invio (con retry e circuit breaker)
Canali supportati:
webhook POST HMAC-SHA256
email SMTP
telegram Bot API
whatsapp Meta Cloud API v18
Sicurezza:
config_enc configurazione sensibile cifrata AES-256-GCM
"""
import uuid
from datetime import datetime
from sqlalchemy import (
BigInteger,
Boolean,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
ChannelType = Enum(
"webhook", "email", "telegram", "whatsapp",
name="notification_channel_type",
create_type=False,
)
NotificationStatus = Enum(
"pending", "sent", "failed", "skipped",
name="notification_status",
create_type=False,
)
class NotificationChannel(Base):
"""
Canale di notifica configurato da un tenant.
Il campo config_enc contiene la configurazione sensibile (API key,
token, SMTP password…) cifrata AES-256-GCM a livello applicativo.
"""
__tablename__ = "notification_channels"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
channel_type: Mapped[str] = mapped_column(ChannelType, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Configurazione NON sensibile (JSON libero: url, chat_id, from_email…)
config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# Configurazione sensibile cifrata (token, password…) base64(GCM(json))
config_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
# Circuit breaker
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
circuit_open_until: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relazioni
rules: Mapped[list["NotificationRule"]] = relationship(
"NotificationRule", back_populates="channel", cascade="all, delete-orphan"
)
logs: Mapped[list["NotificationLog"]] = relationship(
"NotificationLog", back_populates="channel", cascade="all, delete-orphan"
)
__table_args__ = (
Index("idx_notif_channel_tenant", "tenant_id"),
)
def __repr__(self) -> str:
return f"<NotificationChannel {self.name!r} type={self.channel_type!r}>"
class NotificationRule(Base):
"""
Regola: evento PEChub → canale di notifica.
event_type: new_message | state_changed | anomaly | send_failed | ...
filter: JSONB con condizioni opzionali (mailbox_id, state, ecc.)
"""
__tablename__ = "notification_rules"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
channel_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("notification_channels.id", ondelete="CASCADE"),
nullable=False,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
filter: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Relazioni
channel: Mapped["NotificationChannel"] = relationship(
"NotificationChannel", back_populates="rules"
)
__table_args__ = (
Index("idx_notif_rule_tenant", "tenant_id"),
Index("idx_notif_rule_channel", "channel_id"),
Index("idx_notif_rule_event", "event_type"),
)
def __repr__(self) -> str:
return f"<NotificationRule {self.name!r} event={self.event_type!r}>"
class NotificationLog(Base):
"""
Log di ogni tentativo di notifica.
Retry indipendente per canale: max 3 tentativi, backoff 5m → 30m → 2h.
"""
__tablename__ = "notification_log"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
channel_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("notification_channels.id", ondelete="CASCADE"),
nullable=False,
)
rule_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("notification_rules.id", ondelete="SET NULL"),
nullable=True,
)
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
event_payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
status: Mapped[str] = mapped_column(NotificationStatus, nullable=False, default="pending")
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
next_retry_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
http_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
sent_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Relazioni
channel: Mapped["NotificationChannel"] = relationship(
"NotificationChannel", back_populates="logs"
)
__table_args__ = (
Index("idx_notif_log_tenant", "tenant_id"),
Index("idx_notif_log_channel", "channel_id"),
Index("idx_notif_log_status", "status", "next_retry_at"),
)
def __repr__(self) -> str:
return f"<NotificationLog channel={self.channel_id} status={self.status!r}>"