"""Raise and resolve notification events inside the caller's transaction. No I/O here: notify-send mails them. A standing condition stays open (resolved_at null) until it clears. Raising it again while open only counts it. Clearing it closes the row and adds a recovery row that goes to the same rule. A one-shot is inserted resolved. """ from __future__ import annotations import json import logging from typing import Any from psycopg.types.json import Jsonb from .catalog import EVENT_TYPES log = logging.getLogger(__name__) SEVERITIES = ("info", "warning", "critical") TITLE_MAX = 200 BODY_MAX = 8000 SUBJECT_MAX = 200 def _jsonable(value: Any) -> Any: return json.loads(json.dumps(value if value is not None else {}, default=str)) def _clip(text: str | None, n: int) -> str: t = (text or "").strip() return t if len(t) <= n else t[: n - 1] + "…" def raise_event( conn: Any, event_type: str, *, subject: str, title: str, body: str = "", detail: dict[str, Any] | None = None, dept_code: str | None = None, severity: str = "warning", condition: bool = False, dedupe_minutes: int = 0, ) -> int | None: """Record one event. Returns the new row's id, or None when it was deduped (or could not be recorded). Never raises: a notification must not break a sign-in, a reject or a job. A failure is logged. """ if event_type not in EVENT_TYPES: log.error("raise_event: unknown event type %r; not recorded", event_type) return None if severity not in SEVERITIES: severity = "warning" subj = _clip(subject, SUBJECT_MAX) args = (_clip(title, TITLE_MAX), _clip(body, BODY_MAX), Jsonb(_jsonable(detail)), dept_code) try: with conn.transaction(): if condition: row = conn.execute( "INSERT INTO notification_events (kind, event_type, severity, subject, title, body, detail," " dept_code) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)" " ON CONFLICT (kind, coalesce(subject, '')) WHERE resolved_at IS NULL DO UPDATE SET" " raised_count = notification_events.raised_count + 1, last_raised_at = now()," " detail = EXCLUDED.detail, body = EXCLUDED.body" " RETURNING id, (xmax = 0) AS inserted", (event_type, event_type, severity, subj, *args), ).fetchone() return int(row[0]) if row is not None and row[1] else None if dedupe_minutes > 0: recent = conn.execute( "SELECT 1 FROM notification_events WHERE event_type = %s AND subject = %s" " AND created_at > now() - make_interval(mins => %s) LIMIT 1", (event_type, subj, int(dedupe_minutes)), ).fetchone() if recent is not None: return None row = conn.execute( "INSERT INTO notification_events (kind, event_type, severity, subject, title, body, detail," " dept_code, resolved_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, now()) RETURNING id", (event_type, event_type, severity, subj, *args), ).fetchone() return int(row[0]) except Exception: log.exception("raise_event %s failed; not recorded", event_type) return None def resolve_event(conn: Any, event_type: str, *, subject: str, title: str | None = None, body: str = "") -> int | None: """Close the open condition for (event_type, subject) and add its recovery row. Returns the recovery row's id, or None when nothing was open. Never raises.""" if event_type not in EVENT_TYPES: log.error("resolve_event: unknown event type %r", event_type) return None subj = _clip(subject, SUBJECT_MAX) try: with conn.transaction(): row = conn.execute( "UPDATE notification_events SET resolved_at = now() WHERE kind = %s AND coalesce(subject, '') = %s" " AND resolved_at IS NULL RETURNING id, title, dept_code", (event_type, subj), ).fetchone() if row is None: return None open_id, open_title, dept = row base = title or open_title or EVENT_TYPES[event_type].label rec = conn.execute( "INSERT INTO notification_events (kind, event_type, severity, subject, title, body, detail," " dept_code, resolved_at, recovery_of) VALUES (%s, %s, 'info', %s, %s, %s, %s, %s, now(), %s)" " RETURNING id", ( event_type, event_type, subj, _clip(f"Cleared: {base}", TITLE_MAX), _clip(body, BODY_MAX), Jsonb({"recovery_of": open_id}), dept, open_id, ), ).fetchone() return int(rec[0]) except Exception: log.exception("resolve_event %s failed", event_type) return None def open_event(conn: Any, event_type: str, subject: str) -> int | None: row = conn.execute( "SELECT id FROM notification_events WHERE kind = %s AND coalesce(subject, '') = %s AND resolved_at IS NULL", (event_type, subject), ).fetchone() return int(row[0]) if row else None