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:
@@ -1,7 +1,7 @@
|
||||
"""The job registry and the shapes every job uses.
|
||||
|
||||
Jobs import `from packetstation.worker.jobs import JobContext, JobSpec, register` and expose `SPEC`. The feed and
|
||||
drawing jobs are imported lazily by `load_jobs`, so the worker runs before they exist.
|
||||
Jobs import `from packetstation.worker.jobs import JobContext, JobSpec, register` and expose `SPEC`. Every job but
|
||||
housekeeping is imported lazily by `load_jobs`, so a job module that cannot import is logged and left out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -97,7 +97,16 @@ class JobSpec:
|
||||
JOBS: dict[str, JobSpec] = {}
|
||||
_loaded = False
|
||||
_load_lock = threading.Lock()
|
||||
LAZY = ("visual_feed", "drawing_lookup")
|
||||
LAZY = (
|
||||
"visual_feed",
|
||||
"drawing_lookup",
|
||||
"ad_reconcile",
|
||||
"session_refresh",
|
||||
"integrity",
|
||||
"backup_check",
|
||||
"notify_send",
|
||||
"notify_digest",
|
||||
)
|
||||
|
||||
|
||||
def register(spec: JobSpec) -> JobSpec:
|
||||
@@ -117,12 +126,53 @@ def load_jobs() -> dict[str, JobSpec]:
|
||||
try:
|
||||
m = importlib.import_module(f"{__name__}.{mod}")
|
||||
register(m.SPEC)
|
||||
except ImportError as e:
|
||||
except (ImportError, AttributeError) as e:
|
||||
log.warning("job module %s not loaded: %s", mod, redact(str(e)))
|
||||
_loaded = True
|
||||
return JOBS
|
||||
|
||||
|
||||
NOTIFY = __name__.rsplit(".", 2)[0] + ".notify"
|
||||
|
||||
|
||||
def directory(ctx: JobContext) -> Any:
|
||||
"""The worker's directory (one per process, in the stack cache). Tests put a fake under "directory"."""
|
||||
from ...auth.ldap import Directory
|
||||
|
||||
return ctx.stack.cached("directory", lambda: Directory(ctx.settings))
|
||||
|
||||
|
||||
def reload_departments(conn: Any) -> None:
|
||||
"""Load the departments table into the group map, so a department the Admin added counts at once."""
|
||||
from ...auth import groups
|
||||
|
||||
deps = groups.load_departments(conn)
|
||||
if deps:
|
||||
groups.use_departments(deps)
|
||||
|
||||
|
||||
def raise_event(conn: Any, event_type: str, **kw: Any) -> int | None:
|
||||
"""notify.raise_event, or a logged no-op when the notify package is not there."""
|
||||
try:
|
||||
real = importlib.import_module(NOTIFY).raise_event
|
||||
except (ImportError, AttributeError) as e:
|
||||
log.warning("event %s not raised, notify not available: %s", event_type, e)
|
||||
return None
|
||||
result: int | None = real(conn, event_type, **kw)
|
||||
return result
|
||||
|
||||
|
||||
def resolve_event(conn: Any, event_type: str, **kw: Any) -> int | None:
|
||||
"""notify.resolve_event, or a logged no-op when the notify package is not there."""
|
||||
try:
|
||||
real = importlib.import_module(NOTIFY).resolve_event
|
||||
except (ImportError, AttributeError) as e:
|
||||
log.warning("event %s not resolved, notify not available: %s", event_type, e)
|
||||
return None
|
||||
result: int | None = real(conn, event_type, **kw)
|
||||
return result
|
||||
|
||||
|
||||
def seconds_until(now_site: datetime, at: time) -> int:
|
||||
"""Seconds from now_site to the next `at` (site wall clock), at least 1."""
|
||||
target = datetime.combine(now_site.date(), at, tzinfo=now_site.tzinfo)
|
||||
@@ -140,7 +190,11 @@ __all__ = [
|
||||
"JobContext",
|
||||
"JobSpec",
|
||||
"Stack",
|
||||
"directory",
|
||||
"load_jobs",
|
||||
"raise_event",
|
||||
"register",
|
||||
"reload_departments",
|
||||
"resolve_event",
|
||||
"seconds_until",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user