155 lines
6.1 KiB
Python
155 lines
6.1 KiB
Python
"""notify-digest: once a rule's digest time has passed today (site time), send what it queued before then.
|
|||
|
|
|
||
|
|
Recipients are resolved per event, then grouped per address: one message per address listing every event due for
|
||
|
|
it, across rules. One notifications_sent row per message (event_type 'digest'). An outbox row is marked sent once
|
||
|
|
every address it goes to has it; a failed send leaves it for the next run, and an address that already has an
|
||
|
|
event is not sent it again.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import logging
|
||
|
|
from datetime import datetime, time, timedelta
|
||
|
|
from typing import Any
|
||
|
|
from zoneinfo import ZoneInfo
|
||
|
|
|
||
|
|
from psycopg.rows import dict_row
|
||
|
|
|
||
|
|
from ..settings import Settings
|
||
|
|
from . import smtp
|
||
|
|
from .routing import DIRECTORY_DOWN, DirectoryDown, NotifyDirectory, Recipient, Resolver, load_rules
|
||
|
|
from .send import deliver, event_lines, footer, record
|
||
|
|
|
||
|
|
log = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
DEFAULT_TIME = time(7, 0)
|
||
|
|
BACKOFF = timedelta(minutes=30)
|
||
|
|
|
||
|
|
|
||
|
|
def _due_rows(conn: Any, settings: Settings, rules: dict[str, Any], now: datetime) -> list[dict[str, Any]]:
|
||
|
|
tz = ZoneInfo(settings.site_tz)
|
||
|
|
pending = [r[0] for r in conn.execute("SELECT DISTINCT event_type FROM notification_outbox WHERE sent_at IS NULL")]
|
||
|
|
out: list[dict[str, Any]] = []
|
||
|
|
for et in pending:
|
||
|
|
rule = rules.get(et)
|
||
|
|
at = rule.digest_time if rule is not None else DEFAULT_TIME
|
||
|
|
cutoff = datetime.combine(now.astimezone(tz).date(), at, tzinfo=tz)
|
||
|
|
if now < cutoff:
|
||
|
|
continue
|
||
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
||
|
|
out.extend(
|
||
|
|
dict(r)
|
||
|
|
for r in cur.execute(
|
||
|
|
"SELECT o.id AS outbox_id, o.queued_at, e.* FROM notification_outbox o"
|
||
|
|
" JOIN notification_events e ON e.id = o.event_id"
|
||
|
|
" WHERE o.sent_at IS NULL AND o.event_type = %s AND o.queued_at < %s ORDER BY o.id",
|
||
|
|
(et, cutoff),
|
||
|
|
).fetchall()
|
||
|
|
)
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def _already(conn: Any, event_ids: list[int]) -> set[tuple[int, str]]:
|
||
|
|
"""(event id, address) pairs a digest already carried, sent or recorded in dry run."""
|
||
|
|
done: set[tuple[int, str]] = set()
|
||
|
|
for ids, recips in conn.execute(
|
||
|
|
"SELECT event_ids, recipients FROM notifications_sent WHERE digest AND result IN ('sent', 'dry_run')"
|
||
|
|
" AND event_ids && %s::bigint[]",
|
||
|
|
(event_ids,),
|
||
|
|
).fetchall():
|
||
|
|
for rc in recips or []:
|
||
|
|
addr = str(rc.get("address") or "").lower()
|
||
|
|
done.update((int(i), addr) for i in ids or [])
|
||
|
|
return done
|
||
|
|
|
||
|
|
|
||
|
|
def run_digest(
|
||
|
|
conn: Any, settings: Settings, directory: NotifyDirectory | None, now: datetime | None = None
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
"""Send the due digests. Returns counts."""
|
||
|
|
tz = ZoneInfo(settings.site_tz)
|
||
|
|
now = now or datetime.now(tz)
|
||
|
|
last = conn.execute(
|
||
|
|
"SELECT result, at FROM notifications_sent WHERE digest AND NOT test ORDER BY id DESC LIMIT 1"
|
||
|
|
).fetchone()
|
||
|
|
rules = load_rules(conn)
|
||
|
|
rows = _due_rows(conn, settings, rules, now)
|
||
|
|
if not rows:
|
||
|
|
return {"due": 0}
|
||
|
|
if last is not None and last[0] == "failed" and now - last[1] < BACKOFF:
|
||
|
|
return {"due": len(rows), "backoff": True}
|
||
|
|
resolver = Resolver(conn, settings, directory)
|
||
|
|
per_event: dict[int, list[Recipient]] = {}
|
||
|
|
try:
|
||
|
|
for r in rows:
|
||
|
|
rule = rules.get(str(r["event_type"]))
|
||
|
|
per_event[int(r["id"])] = resolver.resolve(rule, r.get("dept_code")) if rule and rule.enabled else []
|
||
|
|
except DirectoryDown:
|
||
|
|
ids = sorted({int(r["id"]) for r in rows})
|
||
|
|
subject = smtp.full_subject(settings, "Daily digest")
|
||
|
|
record(
|
||
|
|
conn,
|
||
|
|
event_type="digest",
|
||
|
|
subject=subject,
|
||
|
|
recipients=[],
|
||
|
|
result="failed",
|
||
|
|
error=DIRECTORY_DOWN,
|
||
|
|
event_ids=ids,
|
||
|
|
digest=True,
|
||
|
|
)
|
||
|
|
return {"due": len(rows), "failed": 1, "error": DIRECTORY_DOWN}
|
||
|
|
done = _already(conn, sorted(per_event))
|
||
|
|
by_addr: dict[str, tuple[Recipient, list[dict[str, Any]]]] = {}
|
||
|
|
seen_event: dict[str, set[int]] = {}
|
||
|
|
for r in rows:
|
||
|
|
eid = int(r["id"])
|
||
|
|
for rc in per_event[eid]:
|
||
|
|
key = rc.address.lower()
|
||
|
|
if (eid, key) in done or eid in seen_event.setdefault(key, set()):
|
||
|
|
continue
|
||
|
|
seen_event[key].add(eid)
|
||
|
|
by_addr.setdefault(key, (rc, []))[1].append(r)
|
||
|
|
failed_events: set[int] = set()
|
||
|
|
sent_by_event: dict[int, int] = {}
|
||
|
|
counts: dict[str, Any] = {"due": len(rows), "messages": 0}
|
||
|
|
for _key, (rc, events) in sorted(by_addr.items()):
|
||
|
|
n = len(events)
|
||
|
|
subject = smtp.full_subject(settings, f"Daily digest: {n} notification{'s' if n != 1 else ''}")
|
||
|
|
lines: list[str] = [f"{n} Packet Station notification{'s' if n != 1 else ''} since the last digest.", ""]
|
||
|
|
for i, ev in enumerate(events, 1):
|
||
|
|
block = event_lines(settings, ev)
|
||
|
|
lines.append(f"{i}. {block[0]}")
|
||
|
|
lines.extend(f" {x}" if x else "" for x in block[1:])
|
||
|
|
lines.append("")
|
||
|
|
lines.extend(footer(settings))
|
||
|
|
ids = [int(e["id"]) for e in events]
|
||
|
|
result, error = deliver(settings, subject, "\n".join(lines), [rc])
|
||
|
|
sid = record(
|
||
|
|
conn,
|
||
|
|
event_type="digest",
|
||
|
|
subject=subject,
|
||
|
|
recipients=[rc],
|
||
|
|
result=result,
|
||
|
|
error=error,
|
||
|
|
event_ids=ids,
|
||
|
|
digest=True,
|
||
|
|
)
|
||
|
|
counts["messages"] += 1
|
||
|
|
counts[result] = counts.get(result, 0) + 1
|
||
|
|
if result in ("sent", "dry_run"):
|
||
|
|
for i in ids:
|
||
|
|
sent_by_event[i] = sid
|
||
|
|
else:
|
||
|
|
failed_events.update(ids)
|
||
|
|
with conn.transaction():
|
||
|
|
for r in rows:
|
||
|
|
eid = int(r["id"])
|
||
|
|
if eid in failed_events:
|
||
|
|
continue
|
||
|
|
conn.execute(
|
||
|
|
"UPDATE notification_outbox SET sent_at = now(), sent_id = %s WHERE id = %s AND sent_at IS NULL",
|
||
|
|
(sent_by_event.get(eid), r["outbox_id"]),
|
||
|
|
)
|
||
|
|
counts["left"] = len({int(r["id"]) for r in rows if int(r["id"]) in failed_events})
|
||
|
|
return counts
|