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