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]>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
"""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,
|
||||
}
|
||||
Reference in New Issue
Block a user