feat: global notification policy severity to channels (notif-22)

Singleton notification_policy table, NOTIFY_MIN_SEVERITY/CHANNELS env,
central dispatch gate, Settings policy UI, migration 007.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 16:19:26 +10:00
parent 9b177c145b
commit ebb450be92
19 changed files with 577 additions and 126 deletions
+9 -13
View File
@@ -10,8 +10,8 @@ from email.mime.text import MIMEText
from sqlalchemy.orm import Session
from app.models import Event, Problem
from app.services.notification_severity import severity_meets_minimum
from app.services.notification_settings import EmailConfig, get_effective_email_config
from app.services.telegram_notify import SEVERITY_ORDER
logger = logging.getLogger(__name__)
@@ -24,14 +24,6 @@ class EmailSendError(Exception):
pass
def _severity_value(value: str) -> int:
return SEVERITY_ORDER.get(value, 0)
def _should_notify_severity(severity: str, *, config: EmailConfig) -> bool:
return _severity_value(severity) >= _severity_value(config.min_severity)
def parse_mail_recipients(mail_to: str) -> list[str]:
return [part.strip() for part in re.split(r"[,;]", mail_to) if part.strip()]
@@ -94,9 +86,11 @@ def send_email_test_message(*, config: EmailConfig | None = None) -> None:
)
def notify_event(event: Event, *, db: Session | None = None) -> None:
def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: bool = True) -> None:
cfg = get_effective_email_config(db)
if not cfg.active or not _should_notify_severity(event.severity, config=cfg):
if not cfg.active:
return
if apply_policy_gate and not severity_meets_minimum(event.severity, cfg.min_severity):
return
host = event.host.hostname if event.host else "unknown"
body = (
@@ -113,9 +107,11 @@ def notify_event(event: Event, *, db: Session | None = None) -> None:
logger.exception("notify_event email failed event_id=%s", event.event_id)
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None:
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None, apply_policy_gate: bool = True) -> None:
cfg = get_effective_email_config(db)
if not cfg.active or not _should_notify_severity(problem.severity, config=cfg):
if not cfg.active:
return
if apply_policy_gate and not severity_meets_minimum(problem.severity, cfg.min_severity):
return
host = problem.host.hostname if problem.host else "unknown"
related = ""
+130
View File
@@ -0,0 +1,130 @@
"""Global notification policy: severity ≥ min → selected channels."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models.notification_channel import NotificationChannel
from app.models.notification_policy import POLICY_ROW_ID, NotificationPolicy
from app.services.notification_settings import (
CHANNEL_EMAIL,
CHANNEL_TELEGRAM,
CHANNEL_WEBHOOK,
VALID_SEVERITIES,
)
DEFAULT_CHANNELS = frozenset({CHANNEL_TELEGRAM, CHANNEL_WEBHOOK, CHANNEL_EMAIL})
@dataclass(frozen=True)
class NotificationPolicyConfig:
min_severity: str
use_telegram: bool
use_webhook: bool
use_email: bool
source: str # env | db
def allows_channel(self, channel: str) -> bool:
if channel == CHANNEL_TELEGRAM:
return self.use_telegram
if channel == CHANNEL_WEBHOOK:
return self.use_webhook
if channel == CHANNEL_EMAIL:
return self.use_email
return False
def _parse_channels_csv(value: str) -> tuple[bool, bool, bool]:
parts = {p.strip().lower() for p in value.split(",") if p.strip()}
return (
CHANNEL_TELEGRAM in parts or "tg" in parts,
CHANNEL_WEBHOOK in parts,
CHANNEL_EMAIL in parts or "mail" in parts or "smtp" in parts,
)
def _policy_from_env() -> NotificationPolicyConfig:
settings = get_settings()
use_tg, use_wh, use_em = _parse_channels_csv(settings.notify_channels)
min_sev = settings.notify_min_severity.strip() or "warning"
if min_sev not in VALID_SEVERITIES:
min_sev = "warning"
return NotificationPolicyConfig(
min_severity=min_sev,
use_telegram=use_tg,
use_webhook=use_wh,
use_email=use_em,
source="env",
)
def get_effective_notification_policy(db: Session | None = None) -> NotificationPolicyConfig:
if db is None:
from app.database import SessionLocal
session = SessionLocal()
try:
return get_effective_notification_policy(session)
finally:
session.close()
row = db.get(NotificationPolicy, POLICY_ROW_ID)
env_cfg = _policy_from_env()
if row is None:
return env_cfg
min_sev = (row.min_severity or "").strip() or env_cfg.min_severity
if min_sev not in VALID_SEVERITIES:
min_sev = env_cfg.min_severity
return NotificationPolicyConfig(
min_severity=min_sev,
use_telegram=bool(row.use_telegram),
use_webhook=bool(row.use_webhook),
use_email=bool(row.use_email),
source="db",
)
def _sync_channel_min_severity(db: Session, min_severity: str) -> None:
for channel in (CHANNEL_TELEGRAM, CHANNEL_WEBHOOK, CHANNEL_EMAIL):
row = db.get(NotificationChannel, channel)
if row is not None:
row.min_severity = min_severity
def upsert_notification_policy(
db: Session,
*,
min_severity: str,
use_telegram: bool,
use_webhook: bool,
use_email: bool,
) -> NotificationPolicyConfig:
if min_severity not in VALID_SEVERITIES:
raise ValueError(f"invalid min_severity: {min_severity}")
row = db.get(NotificationPolicy, POLICY_ROW_ID)
if row is None:
row = NotificationPolicy(
id=POLICY_ROW_ID,
min_severity=min_severity,
use_telegram=use_telegram,
use_webhook=use_webhook,
use_email=use_email,
)
db.add(row)
else:
row.min_severity = min_severity
row.use_telegram = use_telegram
row.use_webhook = use_webhook
row.use_email = use_email
_sync_channel_min_severity(db, min_severity)
db.commit()
db.refresh(row)
return get_effective_notification_policy(db)
@@ -0,0 +1,11 @@
"""Severity ordering for notification policy gates."""
SEVERITY_ORDER = {"info": 10, "warning": 20, "high": 30, "critical": 40}
def severity_value(value: str) -> int:
return SEVERITY_ORDER.get(value, 0)
def severity_meets_minimum(severity: str, minimum: str) -> bool:
return severity_value(severity) >= severity_value(minimum)
+22 -7
View File
@@ -1,18 +1,33 @@
"""Dispatch ingest notifications to all configured channels."""
"""Dispatch ingest notifications per global policy (severity → channels)."""
from sqlalchemy.orm import Session
from app.models import Event, Problem
from app.services import email_notify, telegram_notify, webhook_notify
from app.services.notification_policy import get_effective_notification_policy
from app.services.notification_severity import severity_meets_minimum
from app.services.notification_settings import CHANNEL_EMAIL, CHANNEL_TELEGRAM, CHANNEL_WEBHOOK
def notify_event(event: Event, *, db: Session | None = None) -> None:
telegram_notify.notify_event(event, db=db)
webhook_notify.notify_event(event, db=db)
email_notify.notify_event(event, db=db)
policy = get_effective_notification_policy(db)
if not severity_meets_minimum(event.severity, policy.min_severity):
return
if policy.use_telegram:
telegram_notify.notify_event(event, db=db, apply_policy_gate=False)
if policy.use_webhook:
webhook_notify.notify_event(event, db=db, apply_policy_gate=False)
if policy.use_email:
email_notify.notify_event(event, db=db, apply_policy_gate=False)
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None:
telegram_notify.notify_problem(problem, event, db=db)
webhook_notify.notify_problem(problem, event, db=db)
email_notify.notify_problem(problem, event, db=db)
policy = get_effective_notification_policy(db)
if not severity_meets_minimum(problem.severity, policy.min_severity):
return
if policy.use_telegram:
telegram_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
if policy.use_webhook:
webhook_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
if policy.use_email:
email_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
+9 -15
View File
@@ -4,12 +4,11 @@ import httpx
from sqlalchemy.orm import Session
from app.models import Event, Problem
from app.services.notification_severity import severity_meets_minimum
from app.services.notification_settings import TelegramConfig, get_effective_telegram_config
logger = logging.getLogger(__name__)
SEVERITY_ORDER = {"info": 10, "warning": 20, "high": 30, "critical": 40}
class TelegramNotConfiguredError(Exception):
"""Telegram disabled or missing token/chat_id."""
@@ -21,10 +20,6 @@ class TelegramSendError(Exception):
self.status_code = status_code
def _severity_value(value: str) -> int:
return SEVERITY_ORDER.get(value, 0)
def send_telegram_text(message: str, *, config: TelegramConfig | None = None, force: bool = False) -> None:
cfg = config or get_effective_telegram_config()
if not force and not cfg.active:
@@ -61,14 +56,11 @@ def send_telegram_test_message(*, config: TelegramConfig | None = None) -> None:
)
def _should_notify_severity(severity: str, *, config: TelegramConfig) -> bool:
min_level = _severity_value(config.min_severity)
return _severity_value(severity) >= min_level
def notify_event(event: Event, *, db: Session | None = None) -> None:
def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: bool = True) -> None:
cfg = get_effective_telegram_config(db)
if not cfg.active or not _should_notify_severity(event.severity, config=cfg):
if not cfg.active:
return
if apply_policy_gate and not severity_meets_minimum(event.severity, cfg.min_severity):
return
host = event.host.hostname if event.host else "unknown"
message = (
@@ -85,9 +77,11 @@ def notify_event(event: Event, *, db: Session | None = None) -> None:
logger.exception("notify_event telegram failed event_id=%s", event.event_id)
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None:
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None, apply_policy_gate: bool = True) -> None:
cfg = get_effective_telegram_config(db)
if not cfg.active or not _should_notify_severity(problem.severity, config=cfg):
if not cfg.active:
return
if apply_policy_gate and not severity_meets_minimum(problem.severity, cfg.min_severity):
return
host = problem.host.hostname if problem.host else "unknown"
related = ""
+9 -13
View File
@@ -9,8 +9,8 @@ import httpx
from sqlalchemy.orm import Session
from app.models import Event, Problem
from app.services.notification_severity import severity_meets_minimum
from app.services.notification_settings import WebhookConfig, get_effective_webhook_config
from app.services.telegram_notify import SEVERITY_ORDER
logger = logging.getLogger(__name__)
@@ -25,14 +25,6 @@ class WebhookSendError(Exception):
self.status_code = status_code
def _severity_value(value: str) -> int:
return SEVERITY_ORDER.get(value, 0)
def _should_notify_severity(severity: str, *, config: WebhookConfig) -> bool:
return _severity_value(severity) >= _severity_value(config.min_severity)
def _host_payload(entity: Event | Problem) -> dict[str, Any]:
host = entity.host
if host is None:
@@ -120,9 +112,11 @@ def send_webhook_test_message(*, config: WebhookConfig | None = None) -> None:
)
def notify_event(event: Event, *, db: Session | None = None) -> None:
def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: bool = True) -> None:
cfg = get_effective_webhook_config(db)
if not cfg.active or not _should_notify_severity(event.severity, config=cfg):
if not cfg.active:
return
if apply_policy_gate and not severity_meets_minimum(event.severity, cfg.min_severity):
return
try:
send_webhook_payload(build_event_payload(event), config=cfg)
@@ -130,9 +124,11 @@ def notify_event(event: Event, *, db: Session | None = None) -> None:
logger.exception("notify_event webhook failed event_id=%s", event.event_id)
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None:
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None, apply_policy_gate: bool = True) -> None:
cfg = get_effective_webhook_config(db)
if not cfg.active or not _should_notify_severity(problem.severity, config=cfg):
if not cfg.active:
return
if apply_policy_gate and not severity_meets_minimum(problem.severity, cfg.min_severity):
return
try:
send_webhook_payload(build_problem_payload(problem, event), config=cfg)