Admin: Departments (name and stations; add shows the three groups IT must create), Profiles (welder stamp, station limits, active) with a CSV import that previews and applies only when clean, Checklists (weld and plain), Security (session, lockout and rate-limit settings with bounds, alerts for the last 24 hours, the integrity result), Access log with filters and a guarded CSV, Who is in, Stamp check, Notifications, and the employeeID flags. Every Admin change is audited. Worker: ad-reconcile (nightly and on "refresh from AD now"), session-refresh (15 minutes), integrity (seals, asset hashes, missing and orphan files, the audit chain), backup-check, notify-send and notify-digest. Notifications route per event type to AD groups, users, mailboxes or the department Supervisor group, immediately or in a daily digest, over STARTTLS verified against the PESCO CA, with dry run on the test stack. Backups: scripts/backup/ps-backup.sh and ps-restore.sh with a systemd timer at 02:00 America/Denver. Restore rehearsed on the test stack 2026-10-21. CI: pip-audit and npm audit. pypdf 6.21.1 and certifi 2026.10.19. Migration 0010. Closes the app part of I6, M3, O1, O2 and S3. Co-Authored-By: Claude Opus 5.5 <[email protected]>
265 lines
8.6 KiB
Python
265 lines
8.6 KiB
Python
"""notify-send: deliver the events nobody has handled yet, and the Admin's test send.
|
|
|
|
Per event: rule off -> dispatched `rule_off`; immediate -> one message to the resolved recipients, one
|
|
notifications_sent row, dispatched with the result; digest -> an outbox row, dispatched `queued`.
|
|
NOTIFY_DRY_RUN records `dry_run` and sends nothing, except a test send, which always goes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Sequence
|
|
from datetime import datetime
|
|
from typing import Any
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from psycopg.rows import dict_row
|
|
from psycopg.types.json import Jsonb
|
|
|
|
from ..settings import Settings
|
|
from . import smtp
|
|
from .catalog import EVENT_TYPES
|
|
from .routing import DIRECTORY_DOWN, DirectoryDown, NotifyDirectory, Recipient, Resolver, load_rules
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
MAX_DIRECTORY_TRIES = 3
|
|
RETRY_PREFIX = "directory_retry:"
|
|
BATCH = 200
|
|
FOOTER = "Sent by Packet Station. Who gets this is set on the Admin page, Notifications tab."
|
|
|
|
|
|
def record(
|
|
conn: Any,
|
|
*,
|
|
event_type: str,
|
|
subject: str,
|
|
recipients: Sequence[Recipient],
|
|
result: str,
|
|
error: str | None = None,
|
|
event_ids: Sequence[int] = (),
|
|
digest: bool = False,
|
|
test: bool = False,
|
|
requested_by: Any = None,
|
|
) -> int:
|
|
row = conn.execute(
|
|
"INSERT INTO notifications_sent (event_type, event_ids, digest, test, subject, recipients, result, error,"
|
|
" requested_by) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id",
|
|
(
|
|
event_type,
|
|
list(event_ids),
|
|
digest,
|
|
test,
|
|
subject,
|
|
Jsonb([r.as_json() for r in recipients]),
|
|
result,
|
|
(error or None) and str(error)[:1000],
|
|
requested_by,
|
|
),
|
|
).fetchone()
|
|
return int(row[0])
|
|
|
|
|
|
def deliver(
|
|
settings: Settings, subject: str, body: str, recipients: Sequence[Recipient], *, test: bool = False
|
|
) -> tuple[str, str | None]:
|
|
"""(result, error) for one message. No recipients -> no_recipients; dry run (not a test) -> dry_run."""
|
|
if not recipients:
|
|
return "no_recipients", None
|
|
if settings.notify_dry_run and not test:
|
|
return "dry_run", None
|
|
try:
|
|
note = smtp.send(settings, subject, body, [r.address for r in recipients])
|
|
except smtp.MailError as e:
|
|
log.warning("notify: mail not sent: %s", e)
|
|
return "failed", str(e)
|
|
return "sent", note
|
|
|
|
|
|
def site_time(settings: Settings, at: datetime | None) -> str:
|
|
if at is None:
|
|
return ""
|
|
return at.astimezone(ZoneInfo(settings.site_tz)).strftime("%Y-%m-%d %H:%M")
|
|
|
|
|
|
def label_of(event_type: str) -> str:
|
|
et = EVENT_TYPES.get(event_type)
|
|
return et.label if et else event_type
|
|
|
|
|
|
def event_title(ev: dict[str, Any]) -> str:
|
|
return str(ev.get("title") or label_of(str(ev.get("event_type"))))
|
|
|
|
|
|
def event_lines(settings: Settings, ev: dict[str, Any]) -> list[str]:
|
|
"""The plain-text block for one event (a whole immediate message, or one entry in a digest)."""
|
|
lines = [event_title(ev)]
|
|
if ev.get("body"):
|
|
lines += ["", str(ev["body"]).rstrip()]
|
|
lines.append("")
|
|
lines.append(f"Event: {label_of(str(ev.get('event_type')))}")
|
|
if ev.get("subject"):
|
|
lines.append(f"About: {ev['subject']}")
|
|
if ev.get("dept_code"):
|
|
lines.append(f"Department: {ev['dept_code']}")
|
|
lines.append(f"When: {site_time(settings, ev.get('created_at'))} ({settings.site_tz})")
|
|
if int(ev.get("raised_count") or 1) > 1:
|
|
lines.append(f"Seen {ev['raised_count']} times, last {site_time(settings, ev.get('last_raised_at'))}")
|
|
return lines
|
|
|
|
|
|
def footer(settings: Settings) -> list[str]:
|
|
return ["", "--", f"{FOOTER} Stack {settings.stack}, build {settings.ps_build}."]
|
|
|
|
|
|
def immediate_text(settings: Settings, ev: dict[str, Any]) -> tuple[str, str]:
|
|
return smtp.full_subject(settings, event_title(ev)), "\n".join(event_lines(settings, ev) + footer(settings))
|
|
|
|
|
|
def _tries(result: Any) -> int:
|
|
text = str(result or "")
|
|
if text.startswith(RETRY_PREFIX):
|
|
try:
|
|
return int(text[len(RETRY_PREFIX) :])
|
|
except ValueError:
|
|
return 0
|
|
return 0
|
|
|
|
|
|
def dispatch_pending(conn: Any, settings: Settings, directory: NotifyDirectory | None) -> dict[str, int]:
|
|
"""Handle every undispatched event once. Returns counts by outcome."""
|
|
counts: dict[str, int] = {}
|
|
ids = [
|
|
int(r[0])
|
|
for r in conn.execute(
|
|
"SELECT id FROM notification_events WHERE dispatched_at IS NULL AND event_type IS NOT NULL"
|
|
" ORDER BY id LIMIT %s",
|
|
(BATCH,),
|
|
).fetchall()
|
|
]
|
|
if not ids:
|
|
return counts
|
|
rules = load_rules(conn)
|
|
resolver = Resolver(conn, settings, directory)
|
|
for eid in ids:
|
|
with conn.transaction(), conn.cursor(row_factory=dict_row) as cur:
|
|
ev = cur.execute(
|
|
"SELECT * FROM notification_events WHERE id = %s AND dispatched_at IS NULL FOR UPDATE SKIP LOCKED",
|
|
(eid,),
|
|
).fetchone()
|
|
if ev is None:
|
|
continue
|
|
outcome = _dispatch_one(conn, settings, resolver, rules.get(str(ev["event_type"])), dict(ev))
|
|
counts[outcome] = counts.get(outcome, 0) + 1
|
|
return counts
|
|
|
|
|
|
def _done(conn: Any, eid: int, result: str) -> None:
|
|
conn.execute(
|
|
"UPDATE notification_events SET dispatched_at = now(), dispatch_result = %s WHERE id = %s", (result, eid)
|
|
)
|
|
|
|
|
|
def _dispatch_one(conn: Any, settings: Settings, resolver: Resolver, rule: Any, ev: dict[str, Any]) -> str:
|
|
eid = int(ev["id"])
|
|
if rule is None:
|
|
_done(conn, eid, "no_rule")
|
|
return "no_rule"
|
|
if not rule.enabled:
|
|
_done(conn, eid, "rule_off")
|
|
return "rule_off"
|
|
if rule.delivery == "digest":
|
|
conn.execute("INSERT INTO notification_outbox (event_id, event_type) VALUES (%s, %s)", (eid, ev["event_type"]))
|
|
_done(conn, eid, "queued")
|
|
return "queued"
|
|
subject, body = immediate_text(settings, ev)
|
|
try:
|
|
recipients = resolver.resolve(rule, ev.get("dept_code"))
|
|
except DirectoryDown:
|
|
tries = _tries(ev.get("dispatch_result")) + 1
|
|
record(
|
|
conn,
|
|
event_type=ev["event_type"],
|
|
subject=subject,
|
|
recipients=[],
|
|
result="failed",
|
|
error=DIRECTORY_DOWN,
|
|
event_ids=[eid],
|
|
)
|
|
if tries >= MAX_DIRECTORY_TRIES:
|
|
_done(conn, eid, "failed")
|
|
return "failed"
|
|
conn.execute(
|
|
"UPDATE notification_events SET dispatch_result = %s WHERE id = %s", (f"{RETRY_PREFIX}{tries}", eid)
|
|
)
|
|
return "retry"
|
|
result, error = deliver(settings, subject, body, recipients)
|
|
record(
|
|
conn,
|
|
event_type=ev["event_type"],
|
|
subject=subject,
|
|
recipients=recipients,
|
|
result=result,
|
|
error=error,
|
|
event_ids=[eid],
|
|
)
|
|
_done(conn, eid, result)
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------- test send
|
|
|
|
|
|
def send_test(
|
|
conn: Any,
|
|
settings: Settings,
|
|
directory: NotifyDirectory | None,
|
|
event_type: str,
|
|
*,
|
|
to: str | None = None,
|
|
requested_by: Any = None,
|
|
) -> dict[str, Any]:
|
|
"""Send a test of one event type now, even in dry run. With `to`, only to that address."""
|
|
et = EVENT_TYPES[event_type]
|
|
subject = smtp.full_subject(settings, f"Test: {et.label}")
|
|
body = "\n".join(
|
|
[
|
|
f'This is a test of the Packet Station notification "{et.label}".',
|
|
"",
|
|
et.description,
|
|
"",
|
|
"Nothing has happened. An Admin sent this from the Notifications tab.",
|
|
*footer(settings),
|
|
]
|
|
)
|
|
recipients: list[Recipient] = []
|
|
error: str | None = None
|
|
result: str | None = None
|
|
if to:
|
|
recipients = [Recipient(to.strip(), None, "test address")]
|
|
else:
|
|
rule = load_rules(conn, event_type).get(event_type)
|
|
try:
|
|
recipients = Resolver(conn, settings, directory).resolve(rule, None) if rule else []
|
|
except DirectoryDown:
|
|
result, error = "failed", DIRECTORY_DOWN
|
|
if result is None:
|
|
result, error = deliver(settings, subject, body, recipients, test=True)
|
|
sent_id = record(
|
|
conn,
|
|
event_type=event_type,
|
|
subject=subject,
|
|
recipients=recipients,
|
|
result=result,
|
|
error=error,
|
|
test=True,
|
|
requested_by=requested_by,
|
|
)
|
|
return {
|
|
"result": result,
|
|
"subject": subject,
|
|
"recipients": [r.as_json() for r in recipients],
|
|
"error": error,
|
|
"sent_id": sent_id,
|
|
}
|