Files
PacketStation/server/packetstation/notify/digest.py
T

155 lines
6.1 KiB
Python
Raw Normal View History

"""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