Migration 0004 (VISUAL and drawing caches, go-live ignored list, resource map, job tables, order-derived columns with provenance; work order key includes the operation sequence). The real worker on APScheduler with settings re-read each fire, pause, run now, advisory locks, job_runs and a heartbeat. The visual-feed job (five VisualKit reads, COPY and MERGE cache, suspect-batch guard, the apply in one transaction: drafts, change orders, retire-moved, work order gone, closed and cancelled observed, projects and units from order links, auto-release) and the fingerprint. The drawing-lookup job (queue, budget, download on version change, sha256 into assets, decision table, staging for rev approval, drawing-origin change orders). Clerical From VISUAL panel, flags, rev approval, hand uploads, change-order review; Admin Feed settings and resource map. Synthetic fixture generator and a fixture-driven worker in CI with a Playwright scenario. Docs updated. Verified: 3249 server tests and 13 Playwright scenarios on the docker-1 CI stack, 76 frontend tests, doctor at 0004; one feed and one drawing run against VISUAL DEV and the vault from the developer machine (counts in the changelog). Co-Authored-By: Claude Fable 5.1 <[email protected]>
242 lines
7.7 KiB
Python
242 lines
7.7 KiB
Python
"""Helpers for the feed and drawing tests: the generator, fixture dirs, feed settings and one job run.
|
|
|
|
`run_feed(settings, state)` and `run_drawings(settings)` run the real job functions (visual-feed, drawing-lookup)
|
|
once, the way the worker would, over a connection as the worker role, against the fixture files in
|
|
`settings.fixture_dir`. Never VISUAL or M-Files.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import sys
|
|
from datetime import date, timedelta
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from pshelpers import make_settings
|
|
from psycopg.types.json import Jsonb
|
|
|
|
from packetstation.settings import Settings
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
gen = importlib.import_module("tests.fixtures.generator")
|
|
|
|
FEED_JOB = "visual-feed"
|
|
DRAWING_JOB = "drawing-lookup"
|
|
FEED_COUNT_KEYS = (
|
|
"wos_window",
|
|
"wos_tracked",
|
|
"rows",
|
|
"materials",
|
|
"orders",
|
|
"labor_legs",
|
|
"new_drafts",
|
|
"drafts_updated",
|
|
"cos_opened",
|
|
"cos_reaimed",
|
|
"cos_withdrawn",
|
|
"retired",
|
|
"wo_gone_flagged",
|
|
"closed_observed",
|
|
"ignored_golive",
|
|
"projects_created",
|
|
"units_created",
|
|
"auto_released",
|
|
"unmapped_resources",
|
|
"skipped_removed",
|
|
"skipped_closed",
|
|
"suspect",
|
|
)
|
|
# The counts that are zero when VISUAL did not change between two runs.
|
|
CHANGE_KEYS = (
|
|
"new_drafts",
|
|
"drafts_updated",
|
|
"cos_opened",
|
|
"cos_reaimed",
|
|
"cos_withdrawn",
|
|
"retired",
|
|
"wo_gone_flagged",
|
|
"closed_observed",
|
|
"projects_created",
|
|
"units_created",
|
|
"auto_released",
|
|
)
|
|
|
|
|
|
def fixture_dir(tmp: Path, today: date | None = None) -> Path:
|
|
"""Generate the fixtures into tmp/fixtures (state 1)."""
|
|
out: Path = gen.generate(tmp / "fixtures", today)
|
|
return out
|
|
|
|
|
|
def switch_state(directory: Path, n: int | str) -> None:
|
|
(Path(directory) / "state").write_text(str(n), encoding="utf-8")
|
|
|
|
|
|
def feed_settings_over(base: Settings, tmp: Path) -> Settings:
|
|
"""`base` (a pg_settings) with the fixture sources pointed at freshly generated files under tmp."""
|
|
d = fixture_dir(tmp)
|
|
return base.model_copy(update={"feed_source": "fixture", "drawing_source": "fixture", "fixture_dir": d})
|
|
|
|
|
|
def fixture_settings(tmp: Path, **over: Any) -> Settings:
|
|
"""Plain test settings (no database) with fixture sources."""
|
|
return make_settings(tmp, feed_source="fixture", drawing_source="fixture", fixture_dir=tmp / "fixtures", **over)
|
|
|
|
|
|
def set_feed(settings: Settings, **values: Any) -> None:
|
|
"""Merge values into the `feed` settings row by SQL (no audit: test setup)."""
|
|
from pshelpers import app_conn
|
|
|
|
from packetstation.feed import config
|
|
|
|
with app_conn(settings) as conn:
|
|
cur = config.load(conn).model_dump(mode="json")
|
|
cur.update({k: (v.isoformat() if isinstance(v, date) else v) for k, v in values.items()})
|
|
conn.execute(
|
|
"INSERT INTO settings (key, value, version, updated_at) VALUES ('feed', %s, 1, now())"
|
|
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1",
|
|
(Jsonb(cur),),
|
|
)
|
|
|
|
|
|
def golive_today_offset() -> date:
|
|
return date.today() + timedelta(days=gen.GOLIVE_OFFSET)
|
|
|
|
|
|
# ---------------------------------------------------------------- one job run
|
|
|
|
|
|
def scheduler(settings: Settings) -> Any:
|
|
"""An unstarted worker Scheduler: what runs.run_job needs, without APScheduler or the listener."""
|
|
from packetstation.worker.scheduler import Scheduler
|
|
|
|
return Scheduler(settings, build="pytest")
|
|
|
|
|
|
def run_job(settings: Settings, job: str, *, requested: bool = False, allow_fail: bool = False) -> dict[str, Any]:
|
|
"""Run one registered job once through runs.run_job, as the worker does. Returns the job_runs row.
|
|
|
|
`requested` inserts a job_requests row first, so the run is a "run now". A failed run raises AssertionError
|
|
with its errors unless allow_fail.
|
|
"""
|
|
from packetstation import db
|
|
from packetstation.worker import runs
|
|
|
|
if requested:
|
|
with db.connect_as("worker", settings, autocommit=True) as conn:
|
|
conn.execute("INSERT INTO job_requests (job, note) VALUES (%s, 'pytest')", (job,))
|
|
row = runs.run_job(scheduler(settings), job, "schedule")
|
|
if not allow_fail:
|
|
assert row["status"] != "failed", row.get("errors")
|
|
return dict(row)
|
|
|
|
|
|
def _counts(row: dict[str, Any]) -> dict[str, Any]:
|
|
counts = dict(row.get("counts") or {})
|
|
counts.update(
|
|
{"_status": row["status"], "_errors": row.get("errors") or [], "_run_id": row["id"], "_trigger": row["trigger"]}
|
|
)
|
|
return counts
|
|
|
|
|
|
def run_feed(
|
|
settings: Settings, state: int | None = None, *, requested: bool = False, allow_fail: bool = False
|
|
) -> dict[str, Any]:
|
|
"""Point the fixtures at `state` (when given) and run the visual-feed job once. Returns the run's counts with
|
|
`_status`, `_errors`, `_run_id` and `_trigger` added."""
|
|
if state is not None:
|
|
switch_state(settings.fixture_dir, state)
|
|
return _counts(run_job(settings, FEED_JOB, requested=requested, allow_fail=allow_fail))
|
|
|
|
|
|
def run_drawings(settings: Settings, state: int | None = None, *, requested: bool = False) -> dict[str, Any]:
|
|
if state is not None:
|
|
switch_state(settings.fixture_dir, state)
|
|
return _counts(run_job(settings, DRAWING_JOB, requested=requested))
|
|
|
|
|
|
# ---------------------------------------------------------------- reading back
|
|
|
|
|
|
def packet_by_key(conn: Any, key: str, *, live: bool = True) -> dict[str, Any] | None:
|
|
from psycopg.rows import dict_row
|
|
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
live_sql = "SELECT * FROM packets WHERE visual_key = %s AND removed_at IS NULL ORDER BY id DESC LIMIT 1"
|
|
any_sql = "SELECT * FROM packets WHERE visual_key = %s ORDER BY id DESC LIMIT 1"
|
|
row = cur.execute(live_sql if live else any_sql, (key,)).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def packet_id(conn: Any, key: str) -> int:
|
|
p = packet_by_key(conn, key)
|
|
assert p is not None, key
|
|
return int(p["id"])
|
|
|
|
|
|
def legs_of(conn: Any, pid: int) -> list[tuple[Any, ...]]:
|
|
return [
|
|
tuple(r)
|
|
for r in conn.execute(
|
|
"SELECT wo_base, lot, split, sub, seq, qty, target_hrs, visual_status, missing_runs, gone_at IS NOT NULL"
|
|
" FROM packet_work_orders WHERE packet_id = %s ORDER BY wo_base, lot, split, sub, seq",
|
|
(pid,),
|
|
).fetchall()
|
|
]
|
|
|
|
|
|
def cos_of(conn: Any, pid: int) -> list[dict[str, Any]]:
|
|
from psycopg.rows import dict_row
|
|
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
return [
|
|
dict(r)
|
|
for r in cur.execute("SELECT * FROM change_orders WHERE packet_id = %s ORDER BY co_no", (pid,)).fetchall()
|
|
]
|
|
|
|
|
|
def move_status(conn: Any, pid: int, status: str) -> None:
|
|
"""Move a packet past draft by SQL (test setup: the transitions are tested elsewhere)."""
|
|
conn.execute(
|
|
"UPDATE packets SET status = %s::packet_status, status_at = now(), heat_map = coalesce(heat_map, true),"
|
|
" version = version + 1 WHERE id = %s",
|
|
(status, pid),
|
|
)
|
|
|
|
|
|
def system_audit(conn: Any, action: str) -> list[tuple[Any, ...]]:
|
|
return [
|
|
tuple(r)
|
|
for r in conn.execute(
|
|
"SELECT role_snapshot, user_id, subject_type, subject_id, packet_id FROM audit_events WHERE action = %s"
|
|
" ORDER BY id",
|
|
(action,),
|
|
).fetchall()
|
|
]
|
|
|
|
|
|
__all__ = [
|
|
"CHANGE_KEYS",
|
|
"FEED_COUNT_KEYS",
|
|
"cos_of",
|
|
"feed_settings_over",
|
|
"fixture_dir",
|
|
"fixture_settings",
|
|
"gen",
|
|
"golive_today_offset",
|
|
"legs_of",
|
|
"move_status",
|
|
"packet_by_key",
|
|
"packet_id",
|
|
"run_drawings",
|
|
"run_feed",
|
|
"run_job",
|
|
"set_feed",
|
|
"switch_state",
|
|
"system_audit",
|
|
]
|