135 lines
5.3 KiB
Python
135 lines
5.3 KiB
Python
"""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
|