Files
PacketStation/server/packetstation/worker/jobs/__init__.py
T
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

201 lines
6.1 KiB
Python

"""The job registry and the shapes every job uses.
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
import importlib
import logging
import threading
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import UTC, date, datetime, time, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from ...feed.config import FeedSettings
from ...settings import Settings
from ..redaction import MAX_ERRORS, clip, redact
log = logging.getLogger(__name__)
Counts = dict[str, Any]
Cadence = Callable[[FeedSettings, datetime], int | None]
TRIGGERS = ("schedule", "request", "chain", "cli")
@dataclass
class Stack:
"""Per-process state shared by every run: the startup guard result and long-lived objects (the M-Files vault)."""
guard: Any = None
cache: dict[str, Any] = field(default_factory=dict)
lock: threading.Lock = field(default_factory=threading.Lock)
def cached(self, key: str, factory: Callable[[], Any]) -> Any:
"""The object under `key`, made once by `factory`. A factory that raises leaves nothing cached."""
with self.lock:
if key not in self.cache:
self.cache[key] = factory()
return self.cache[key]
def drop(self, key: str) -> None:
with self.lock:
self.cache.pop(key, None)
@dataclass
class JobContext:
"""What a run gets. `conn` is the run's own autocommit connection as the worker role."""
conn: Any
settings: Settings
feed_settings: FeedSettings
run_id: int
trigger: str
started_at: datetime
log: logging.Logger
stack: Stack
degraded: bool = False
errors: list[str] = field(default_factory=list)
visual_target: str | None = None
def error(self, text: str, *, degrade: bool = True) -> None:
"""Record one error (redacted, clipped). It makes the run degraded unless degrade is False."""
if len(self.errors) < MAX_ERRORS:
self.errors.extend(clip([text]))
if degrade:
self.degraded = True
def degrade(self, reason: str | None = None) -> None:
self.degraded = True
if reason:
self.error(reason)
@property
def tz(self) -> ZoneInfo:
return ZoneInfo(self.settings.site_tz)
def now_site(self) -> datetime:
return datetime.now(self.tz)
def today_site(self) -> date:
return self.now_site().date()
@dataclass(frozen=True)
class JobSpec:
name: str
run: Callable[[JobContext], Counts]
cadence: Cadence # seconds to the next run, or None for "no schedule"
pausable: bool # the feed pause skips scheduled fires (requests still run)
chain: tuple[str, ...] = () # moved to now after a run that did not fail
JOBS: dict[str, JobSpec] = {}
_loaded = False
_load_lock = threading.Lock()
LAZY = (
"visual_feed",
"drawing_lookup",
"ad_reconcile",
"session_refresh",
"integrity",
"backup_check",
"notify_send",
"notify_digest",
)
def register(spec: JobSpec) -> JobSpec:
JOBS[spec.name] = spec
return spec
def load_jobs() -> dict[str, JobSpec]:
"""Register every job once and return the registry. A job module that cannot import is logged and left out."""
global _loaded
with _load_lock:
if not _loaded:
from . import housekeeping
register(housekeeping.SPEC)
for mod in LAZY:
try:
m = importlib.import_module(f"{__name__}.{mod}")
register(m.SPEC)
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)
if target <= now_site:
target = datetime.combine(now_site.date() + timedelta(days=1), at, tzinfo=now_site.tzinfo)
# Aware datetimes sharing a tzinfo subtract as wall clock; through UTC a DST change counts right
return max(1, int((target.astimezone(UTC) - now_site.astimezone(UTC)).total_seconds()))
__all__ = [
"JOBS",
"TRIGGERS",
"Cadence",
"Counts",
"JobContext",
"JobSpec",
"Stack",
"directory",
"load_jobs",
"raise_event",
"register",
"reload_departments",
"resolve_event",
"seconds_until",
]