Phase 9: Admin completion, reconcile, notifications, integrity, backups
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

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:
2026-10-21 18:27:09 -06:00
co-authored by Claude Opus 5.5
parent ce28d2f3c6
commit a82e056b6a
116 changed files with 15125 additions and 297 deletions
+34
View File
@@ -30,6 +30,7 @@ from ..api import deps
from ..api.errors import ApiError
from ..auth import signatures
from ..auth.signatures import Signature
from ..notify import raise_event
from ..policy.actions import SCOPED_LABELS
from ..policy.engine import check_scoped
from ..text import LIMITS, clean_opt, reason_ok
@@ -593,5 +594,38 @@ def _finish(w: ItpWrite) -> None:
events.publish(conn, {"event": "changed", "kind": "itp", "unit_id": w.itp["unit_id"], "itp_id": w.id})
def reject_dept(conn: Any, unit_id: Any, page: Any) -> str | None:
"""The department of a rejected line: the one department among the unit's live packets that works to the
line's page, or None when there is none or more than one."""
codes = {
str(r[0])
for r in conn.execute(
"SELECT DISTINCT dept_code FROM packets WHERE unit_id = %s AND removed_at IS NULL", (unit_id,)
).fetchall()
}
hits = sorted(c for c in codes if page and page in DEPT_PAGES.get(c, ()))
return hits[0] if len(hits) == 1 else None
def notify_reject(w: ItpWrite, key: str, ncr: str | None, note: str | None) -> int | None:
"""qc_reject for an ITP line reject, in the write's transaction (notify-send mails it)."""
item = next((i for i in items(w.itp.get("template") or {}) if str(i.get("n")) == key), {})
page = item.get("page")
dept = reject_dept(w.conn, w.itp["unit_id"], page)
who = (w.principal.user or {}).get("display_name") or ""
head = f"NCR {ncr}" if ncr else "NCR # pending"
lines = [str(item.get("t") or ""), head, note or "", f"Rejected by {who}." if who else ""]
where = f" ({PAGE_NAMES.get(str(page), page)})" if page else ""
return raise_event(
w.conn,
"qc_reject",
subject=f"itp:{w.id}:{key}",
title=f"ITP {w.itp.get('no') or w.id} line {key} rejected{where}",
body="\n".join(x for x in lines if x),
detail={"itp_id": w.id, "unit_id": w.itp["unit_id"], "line": key, "page": page, "ncr": ncr},
dept_code=dept,
)
def label(action: policy.Action) -> str:
return SCOPED_LABELS.get(action, str(action))