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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user