Files
spencermandClaude Opus 5.5 a82e056b6a
ci / frontend (pull_request) Successful in 1m11s
ci / server (pull_request) Successful in 12m2s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 6m1s
Phase 9: Admin completion, reconcile, notifications, integrity, backups
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]>
2026-10-21 18:27:09 -06:00

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,
}