Phase 8: Project Management, find bar, presence and motion
The Project Management screen (/pm, view only) with Overview, Projects & units, Bottlenecks & aging, Hours vs target, Quality & issues and Find jobs, the period picker, health per unit and per project, drill down, Excel (CSV) per table and for all, and the PESCO production status report PDF with the controlled-copy stamp; every export logged as "report exported". The find bar on every home screen: the full search set, the dropdowns, quick flags with Mine, the flag kinds, Board and List with sorts, filters saved per browser; every filter applied by the server on the list and the boards, with facets. Presence in packets with role, lock and revoke drop; the motion switch with reduced motion honoured, card glides, glow and count-up; Back restores the scroll spot; sheet placement and the iPad layout pass. The data book compared page by page with the old build and six layout differences fixed. Tests for the PM aggregates, search, filters, presence and exports; Playwright scenarios for the PM screen, the find bar and presence at desktop and iPad viewports. Docs updated; PLAN.md questions 38 to 42. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,623 @@
|
||||
"""Helpers for the phase 8 tests: one database per test module, the Project Management world, and the texts of the
|
||||
phase 8 contract (periods, PM steps, health, KPI labels, table titles and heads).
|
||||
|
||||
Built on shophelpers, qchelpers and closehelpers (imported, never edited). Nothing here reads VISUAL or M-Files.
|
||||
The labor hours are rows written straight into `visual_labor`, as the feed tests do.
|
||||
|
||||
The PM world (dates are calendar days in the site time zone, America/Denver):
|
||||
|
||||
project L670 "Pump skids" (Acme Gas, PO-9, due today + 20), alias OLD670
|
||||
unit 001 "Skid A" due yesterday -> late k_run (in progress, timer 6 h, ticket 3.2 h, target 4.5 h),
|
||||
k_short (warehouse pulling, an open short with a past ETA)
|
||||
unit 002 "Skid B" due today + 4 -> risk k_rev (review after a Q.C. reject), k_qc (at Q.C.)
|
||||
unit 003 "Skid C" due today + 30 -> risk k_hold (warehouse hold for 5 days), k_inc (warehouse waiting)
|
||||
unit 004 "Skid D" due today + 30 -> risk k_co (ready, change order CO-1 open 3 days), k_pulled
|
||||
unit 005 "Skid E" due today + 30 -> done k_filed (filed, created 10 days ago), k_oldfiled (filed 120
|
||||
days ago, created 140 days ago, a synthetic history)
|
||||
unit 006 "Skid F" no due (project's) -> ok k_draft, k_ready, k_asg (assigned), k_complete
|
||||
unit 007 "Skid G" due today + 30 -> empty
|
||||
project L489 "BPX" (due today + 9), unit 052 with no packets -> empty
|
||||
project L333 closed, unit 001 with no packets: not in scope
|
||||
|
||||
History rows inserted with old timestamps (append-only tables, so inserted, never updated):
|
||||
k_draft: return_to_clerical 400 and 45 days ago (sent back)
|
||||
k_oldfiled: send_to_warehouse -139 d, wh_send_back -138 d (sent back), send_to_warehouse -137 d, wh_release -130 d,
|
||||
send_to_qc -126 d, qc_reject -125 d (sent back; a rejected qc_history row), send_to_qc -124 d,
|
||||
qc_accept -123 d (an accepted qc_history row), file -120 d.
|
||||
|
||||
So: open 12, late units 1, units at risk 3, holds 1, change orders 1, shorts 1, over target 1; by period
|
||||
rejects 30: 1, 90: 1, 365: 2, all: 2; filed 30: 1, 90: 1, 365: 2, all: 2;
|
||||
sent back 30: 1, 90: 2, 365: 4, all: 5; lead time 90: 10.0 d, 365: 15.0 d.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
from fakedir import FakeDirectory
|
||||
from fastapi.testclient import TestClient
|
||||
from pkthelpers import step, tiny_pdf, upload
|
||||
from pshelpers import ORIGIN_HEADERS, PG_URL, app_conn, fake_visual, make_settings
|
||||
from shophelpers import (
|
||||
SIGN,
|
||||
Shop,
|
||||
draft,
|
||||
issue_co,
|
||||
ok,
|
||||
open_shop,
|
||||
pull_all,
|
||||
ready_skipped,
|
||||
run,
|
||||
set_running_since,
|
||||
start,
|
||||
to_station,
|
||||
to_warehouse,
|
||||
)
|
||||
|
||||
from packetstation.settings import Settings
|
||||
|
||||
SITE_TZ = "America/Denver"
|
||||
|
||||
# ---------------------------------------------------------------- the contract's texts
|
||||
|
||||
PERIODS = {"30": "Last 30 days", "90": "Last 90 days", "365": "Last 12 months", "all": "All time"}
|
||||
|
||||
PM_STEPS = (
|
||||
("draft", "Ready to release (Clerical)"),
|
||||
("wh_incoming", "Warehouse: waiting"),
|
||||
("wh_pulling", "Warehouse: being pulled"),
|
||||
("wh_hold", "Warehouse: on hold"),
|
||||
("wh_pulled", "Warehouse: pulled, ready for production"),
|
||||
("ready", "Ready to assign"),
|
||||
("station", "At a station"),
|
||||
("review", "Supervisor review"),
|
||||
("qc", "At Q.C."),
|
||||
("complete", "Back for data book"),
|
||||
)
|
||||
STEP_LABEL = dict(PM_STEPS)
|
||||
|
||||
HEALTH = {"ok": "On track", "risk": "At risk", "late": "Late", "done": "Complete", "empty": "No packets yet"}
|
||||
|
||||
KPI_KEYS = ("open", "late_units", "risk_units", "holds", "cos", "shorts", "rejects", "over", "filed", "lead")
|
||||
KPI_TABS = ("aging", "projects", "projects", "quality", "quality", "quality", "quality", "hours", "projects", "aging")
|
||||
KPI_ALERT = {"late_units", "risk_units", "holds", "cos", "shorts", "rejects", "over"}
|
||||
|
||||
|
||||
def kpi_labels(period: str = "90") -> tuple[str, ...]:
|
||||
lower = PERIODS[period].lower()
|
||||
return (
|
||||
"Open packets",
|
||||
"Late units",
|
||||
"Units at risk",
|
||||
"Warehouse holds",
|
||||
"Open change orders",
|
||||
"Open shorts",
|
||||
f"Q.C. rejects · {lower}",
|
||||
"Packets over target hours",
|
||||
f"Filed · {lower}",
|
||||
"Average lead time, created → filed",
|
||||
)
|
||||
|
||||
|
||||
HOURS_TAIL = ["Packets", "Timer h", "Ticket h", "Target h", "Variance h", "Timer / target", "Over target"]
|
||||
HEADS: dict[str, list[str]] = {
|
||||
"projects": [
|
||||
"Project / unit",
|
||||
"Name",
|
||||
"Customer",
|
||||
"Due",
|
||||
"Left",
|
||||
"Units",
|
||||
"Packets",
|
||||
"Draft",
|
||||
"Warehouse",
|
||||
"Shop",
|
||||
"Q.C.",
|
||||
"Done",
|
||||
"% done",
|
||||
"Health",
|
||||
],
|
||||
"aging_steps": ["Step", "Packets", "Average", "Longest", "Oldest packet"],
|
||||
"aging_open": ["Job", "Unit", "Step", "With", "In this step", "Total age", "Unit due", "Flags"],
|
||||
"aging_history": ["Step", "Moves", "Average", "Longest"],
|
||||
"hours_dept": ["Department", *HOURS_TAIL],
|
||||
"hours_worker": ["Worker", *HOURS_TAIL],
|
||||
"hours_project": ["Project", *HOURS_TAIL],
|
||||
"hours_over": ["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by"],
|
||||
"quality_rejects": ["Job", "Date", "Inspector", "Reason / NCR", "Station / worker"],
|
||||
"quality_shorts": ["Job", "Item", "Short qty", "Reason", "PO / req", "ETA", "Age", "Packet is"],
|
||||
"quality_cos": ["Job", "Change order", "Change", "Waiting on", "Open for"],
|
||||
"quality_holds": ["Job", "Reason", "Note", "Put on hold by", "On hold for"],
|
||||
"quality_sentback": ["Job", "When", "Action", "By", "Reason"],
|
||||
"jobs": [
|
||||
"Job",
|
||||
"Project",
|
||||
"Unit",
|
||||
"Operation",
|
||||
"Title",
|
||||
"Department",
|
||||
"Step",
|
||||
"With",
|
||||
"Station",
|
||||
"Worker",
|
||||
"Drawing",
|
||||
"Rev",
|
||||
"Unit due",
|
||||
"Days in step",
|
||||
"Timer h",
|
||||
"Ticket h",
|
||||
"Target h",
|
||||
"Created",
|
||||
"Updated",
|
||||
"Change orders open",
|
||||
"Shorts open",
|
||||
],
|
||||
}
|
||||
ATTENTION_HEAD = ["Unit", "Description", "Due", "Left", "Packets", "% done", "Health"]
|
||||
|
||||
|
||||
def titles(period: str = "90") -> dict[str, str]:
|
||||
p = PERIODS[period]
|
||||
return {
|
||||
"projects": "Projects and units",
|
||||
"aging_steps": "Time in the current step (open packets)",
|
||||
"aging_open": "Every open packet, longest waiting first",
|
||||
"aging_history": f"Average time per step, moves completed ({p})",
|
||||
"hours_dept": "By department",
|
||||
"hours_worker": "By welder / worker",
|
||||
"hours_project": "By project",
|
||||
"hours_over": "Packets over target",
|
||||
"quality_rejects": f"Q.C. rejects ({p})",
|
||||
"quality_shorts": "Open shorts",
|
||||
"quality_cos": "Open change orders",
|
||||
"quality_holds": "Warehouse holds",
|
||||
"quality_sentback": f"Sent back ({p})",
|
||||
"jobs": "All jobs in scope",
|
||||
}
|
||||
|
||||
|
||||
NOTES = {
|
||||
"projects": "Tap a project to show its units. Done = accepted by Q.C. or filed. At risk = due within 7 days and"
|
||||
" under 75% done, or a hold or open change order.",
|
||||
"aging_steps": "How long packets have been sitting where they are right now.",
|
||||
"aging_open": "Over 3 days in one step is flagged.",
|
||||
"aging_history": "From the step history recorded on every move.",
|
||||
"hours_over": "Station timer hours and VISUAL ticket hours against the work-order target hours. The timer is the"
|
||||
" clock of record.",
|
||||
}
|
||||
|
||||
TAB_TABLES = {
|
||||
"aging": ("aging_steps", "aging_open", "aging_history"),
|
||||
"hours": ("hours_dept", "hours_worker", "hours_project", "hours_over"),
|
||||
"quality": ("quality_rejects", "quality_shorts", "quality_cos", "quality_holds", "quality_sentback"),
|
||||
}
|
||||
|
||||
REJECT_REASON = "Undercut on weld 1, regrind and reweld"
|
||||
REJECT_NCR = "NCR-2026-0031"
|
||||
HOLD_REASON = "Waiting on purchasing / PO"
|
||||
HOLD_NOTE = "PO 4471"
|
||||
FORMULA_TITLE = "=HYPERLINK(1)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- one database per module
|
||||
|
||||
|
||||
def today_site() -> date:
|
||||
return datetime.now(ZoneInfo(SITE_TZ)).date()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def module_settings(tmp: Path) -> Iterator[Settings]:
|
||||
"""The conftest `pg_settings` for a whole module: a fresh database with ps_migrate and ps_app, migrated to head,
|
||||
dropped afterwards. Lets a module build its world once."""
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
from psycopg.conninfo import conninfo_to_dict
|
||||
|
||||
from packetstation import db, migrate
|
||||
|
||||
assert PG_URL
|
||||
info = conninfo_to_dict(PG_URL)
|
||||
mig_pw, app_pw = secrets.token_urlsafe(18), secrets.token_urlsafe(18)
|
||||
(tmp / "mig").write_text(mig_pw + "\n")
|
||||
(tmp / "app").write_text(app_pw + "\n")
|
||||
name = f"ps_test_{secrets.token_hex(4)}"
|
||||
s = make_settings(
|
||||
tmp,
|
||||
pg_host=info.get("host", "localhost"),
|
||||
pg_port=int(info.get("port", 5432)),
|
||||
pg_database=name,
|
||||
pg_migrate_password_file=tmp / "mig",
|
||||
pg_app_password_file=tmp / "app",
|
||||
pg_superuser_url=PG_URL,
|
||||
# A world takes minutes to build over a slow tunnel: no idle lock or idle sign-out while it does.
|
||||
session_lock_minutes=600,
|
||||
session_idle_minutes=720,
|
||||
)
|
||||
migrate.bootstrap_roles(s)
|
||||
with psycopg.connect(PG_URL, autocommit=True) as su:
|
||||
for user, pw in (("ps_migrate", mig_pw), ("ps_app", app_pw)):
|
||||
su.execute(sql.SQL("ALTER ROLE {} LOGIN PASSWORD {}").format(sql.Identifier(user), sql.Literal(pw)))
|
||||
with db.connect_as("migrate", s, autocommit=True) as conn:
|
||||
migrate.run(conn, db.MIGRATIONS_DIR)
|
||||
try:
|
||||
yield s
|
||||
finally:
|
||||
db.close_pool()
|
||||
with psycopg.connect(PG_URL, autocommit=True) as su:
|
||||
su.execute(sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(sql.Identifier(name)))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def module_shop(tmp: Path, fake_dir: FakeDirectory | None = None) -> Iterator[Shop]:
|
||||
"""The app on a module database with the fake directory and DEV VISUAL settings; every test user signed in."""
|
||||
from packetstation import guard
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(guard, "load_visual_settings", lambda: fake_visual(False))
|
||||
with module_settings(tmp) as s:
|
||||
gen = open_shop(s, fake_dir or FakeDirectory())
|
||||
shop = next(gen)
|
||||
try:
|
||||
yield shop
|
||||
finally:
|
||||
gen.close()
|
||||
|
||||
|
||||
def anon(shop: Shop) -> TestClient:
|
||||
"""A client with no session."""
|
||||
return TestClient(shop.main.app, headers=ORIGIN_HEADERS)
|
||||
|
||||
|
||||
def get_ok(c: TestClient, url: str, **params: Any) -> dict[str, Any]:
|
||||
r = c.get(url, params={k: v for k, v in params.items() if v is not None})
|
||||
assert r.status_code == 200, (url, params, r.text)
|
||||
out: dict[str, Any] = r.json()
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- SQL setup
|
||||
|
||||
|
||||
def make_project(
|
||||
settings: Settings,
|
||||
number: str,
|
||||
name: str,
|
||||
*,
|
||||
customer: str | None = None,
|
||||
po: str | None = None,
|
||||
due: date | None = None,
|
||||
status: str = "open",
|
||||
) -> int:
|
||||
with app_conn(settings) as conn:
|
||||
row = conn.execute(
|
||||
"INSERT INTO projects (number, name, customer, po, due, status)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s::project_status) RETURNING id",
|
||||
(number, name, customer, po, due, status),
|
||||
).fetchone()
|
||||
return int(row[0])
|
||||
|
||||
|
||||
def make_unit(settings: Settings, project_id: int, lot: str, name: str | None = None, due: date | None = None) -> int:
|
||||
with app_conn(settings) as conn:
|
||||
row = conn.execute(
|
||||
"INSERT INTO units (project_id, lot, name, due) VALUES (%s, %s, %s, %s) RETURNING id",
|
||||
(project_id, lot, name, due),
|
||||
).fetchone()
|
||||
return int(row[0])
|
||||
|
||||
|
||||
def add_alias(settings: Settings, project_id: int, alias: str) -> None:
|
||||
run(
|
||||
settings,
|
||||
"INSERT INTO project_aliases (alias, project_id, kind) VALUES (%s, %s, 'import')",
|
||||
(alias.upper(), project_id),
|
||||
)
|
||||
|
||||
|
||||
def transition_at(
|
||||
settings: Settings,
|
||||
packet_id: int,
|
||||
days_ago: float,
|
||||
action: str,
|
||||
from_status: str | None,
|
||||
to_status: str | None,
|
||||
*,
|
||||
by: str = "clerk.clara",
|
||||
reason: str | None = None,
|
||||
dept: str = "03",
|
||||
) -> None:
|
||||
"""One packet_transitions row with an old timestamp (the table is append-only: insert, never update)."""
|
||||
run(
|
||||
settings,
|
||||
"INSERT INTO packet_transitions (packet_id, at, by, action, from_status, to_status, from_dept, to_dept,"
|
||||
" reason) VALUES (%s, now() - make_interval(secs => %s), (SELECT id FROM users WHERE sam_account = %s),"
|
||||
" %s, %s::packet_status, %s::packet_status, %s, %s, %s)",
|
||||
(packet_id, days_ago * 86400, by, action, from_status, to_status, dept, dept, reason),
|
||||
)
|
||||
|
||||
|
||||
def qc_history_at(settings: Settings, packet_id: int, days_ago: float, result: str, note: str | None = None) -> None:
|
||||
run(
|
||||
settings,
|
||||
"INSERT INTO qc_history (packet_id, at, result, inspector, note, dept_code, snapshot)"
|
||||
" VALUES (%s, now() - make_interval(secs => %s), %s, 'Quinn QC', %s, '03', '{}')",
|
||||
(packet_id, days_ago * 86400, result, note),
|
||||
)
|
||||
|
||||
|
||||
def labor_rows(settings: Settings, packet_id: int, rows: list[tuple[str, int, str]]) -> None:
|
||||
"""Make a packet a VISUAL packet and give its work orders VISUAL ticket hours: rows of (wo_base, seq, hours).
|
||||
Each work order gets the seq of its row; visual_labor gets one row per entry, as the feed writes them."""
|
||||
with app_conn(settings) as conn:
|
||||
conn.execute("UPDATE packets SET source_kind = 'visual' WHERE id = %s", (packet_id,))
|
||||
for base, seq, hours in rows:
|
||||
conn.execute(
|
||||
"UPDATE packet_work_orders SET seq = %s WHERE packet_id = %s AND wo_base = %s", (seq, packet_id, base)
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO visual_labor (wo_base, lot, split, sub, seq, hours, tickets, last_ticket)"
|
||||
" SELECT wo_base, lot, split, sub, seq, %s, 2, current_date FROM packet_work_orders"
|
||||
" WHERE packet_id = %s AND wo_base = %s"
|
||||
" ON CONFLICT (wo_base, lot, split, sub, seq) DO UPDATE SET hours = EXCLUDED.hours",
|
||||
(hours, packet_id, base),
|
||||
)
|
||||
|
||||
|
||||
def quick_draft(
|
||||
shop: Shop, unit_id: int, title: str, *, n: int, send: str | None = None, **over: Any
|
||||
) -> dict[str, Any]:
|
||||
"""A hand draft by clerk.clara with neutral values (drawing D-61nn, work order Wnn XY, no BOM) unless
|
||||
overridden. `send` "warehouse" sends it to the warehouse; a department code releases it there (ready). Either
|
||||
attaches a one-page drawing and answers the heat map first. A bare draft costs one request."""
|
||||
body: dict[str, Any] = {
|
||||
"unit_id": unit_id,
|
||||
"dept_code": "03",
|
||||
"op": "WELD-PART",
|
||||
"title": title,
|
||||
"customer": "Plains Midstream",
|
||||
"drawing": f"D-61{n:02d}",
|
||||
"rev": "A",
|
||||
"wps": "WPS-11",
|
||||
"work_orders": [
|
||||
{"wo_base": f"W{n:02d}XY", "lot": "001", "split": "0", "sub": "0", "qty": 1, "target_hrs": 1},
|
||||
],
|
||||
"bom": [],
|
||||
}
|
||||
body.update(over)
|
||||
c = shop("clerk.clara")
|
||||
r = c.post("/api/packets", json=body)
|
||||
assert r.status_code == 201, r.text
|
||||
pkt: dict[str, Any] = r.json()
|
||||
if send:
|
||||
assert c.patch(f"/api/packets/{pkt['id']}", json={"set": {"heat_map": True}, "base": {}}).status_code == 200
|
||||
a = upload(c, tiny_pdf(1))
|
||||
assert a.status_code == 201, a.text
|
||||
pkt = ok(
|
||||
c.post(
|
||||
f"/api/packets/{pkt['id']}/pages", json={"asset_id": a.json()["id"], "type": "drawing", "sign": SIGN}
|
||||
)
|
||||
)
|
||||
if send == "warehouse":
|
||||
pkt = ok(step(c, pkt, "send_to_warehouse"))
|
||||
assert pkt["status"] == "warehouse", pkt["status"]
|
||||
else:
|
||||
pkt = ok(step(c, pkt, "release_to_shop", dept=send))
|
||||
assert pkt["status"] == "ready", pkt["status"]
|
||||
return pkt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the PM world
|
||||
|
||||
|
||||
@dataclass
|
||||
class PmWorld:
|
||||
shop: Shop
|
||||
today: date
|
||||
projects: dict[str, int] = field(default_factory=dict) # "L670", "L489", "L333"
|
||||
units: dict[int, int] = field(default_factory=dict) # 1..7 -> L670 units; 52 -> L489 052
|
||||
k: dict[str, int] = field(default_factory=dict) # packet ids by key
|
||||
|
||||
@property
|
||||
def settings(self) -> Settings:
|
||||
return self.shop.settings
|
||||
|
||||
def unit_number(self, n: int) -> str:
|
||||
return f"L670{n:03d}"
|
||||
|
||||
|
||||
OPEN_KEYS = (
|
||||
"k_run",
|
||||
"k_short",
|
||||
"k_rev",
|
||||
"k_qc",
|
||||
"k_hold",
|
||||
"k_inc",
|
||||
"k_co",
|
||||
"k_pulled",
|
||||
"k_draft",
|
||||
"k_ready",
|
||||
"k_asg",
|
||||
"k_complete",
|
||||
)
|
||||
FILED_KEYS = ("k_filed", "k_oldfiled")
|
||||
|
||||
# The PM step of each open packet, and the expected pipeline counts.
|
||||
STEP_OF = {
|
||||
"k_draft": "draft",
|
||||
"k_inc": "wh_incoming",
|
||||
"k_short": "wh_pulling",
|
||||
"k_hold": "wh_hold",
|
||||
"k_pulled": "wh_pulled",
|
||||
"k_ready": "ready",
|
||||
"k_co": "ready",
|
||||
"k_asg": "station",
|
||||
"k_run": "station",
|
||||
"k_rev": "review",
|
||||
"k_qc": "qc",
|
||||
"k_complete": "complete",
|
||||
}
|
||||
PIPELINE_COUNTS = {key: sum(1 for v in STEP_OF.values() if v == key) for key, _ in PM_STEPS}
|
||||
|
||||
UNIT_HEALTH = {1: "late", 2: "risk", 3: "risk", 4: "risk", 5: "done", 6: "ok", 7: "empty"}
|
||||
|
||||
# By period: Q.C. rejects, filed, sent back, lead time.
|
||||
BY_PERIOD = {
|
||||
"30": {"rejects": 1, "filed": 1, "sentback": 1},
|
||||
"90": {"rejects": 1, "filed": 1, "sentback": 2, "lead": "10.0 d"},
|
||||
"365": {"rejects": 2, "filed": 2, "sentback": 4, "lead": "15.0 d"},
|
||||
"all": {"rejects": 2, "filed": 2, "sentback": 5},
|
||||
}
|
||||
|
||||
|
||||
def pm_world(shop: Shop) -> PmWorld:
|
||||
"""Build the PM world described at the top of this file. Takes a few minutes over a slow tunnel."""
|
||||
from closehelpers import at_complete, filed
|
||||
from qchelpers import at_qc, reject
|
||||
|
||||
s = shop.settings
|
||||
today = today_site()
|
||||
w = PmWorld(shop, today)
|
||||
pa = make_project(s, "L670", "Pump skids", customer="Acme Gas", po="PO-9", due=today + timedelta(days=20))
|
||||
add_alias(s, pa, "OLD670")
|
||||
w.projects["L670"] = pa
|
||||
dues = {
|
||||
1: today - timedelta(days=1),
|
||||
2: today + timedelta(days=4),
|
||||
3: today + timedelta(days=30),
|
||||
4: today + timedelta(days=30),
|
||||
5: today + timedelta(days=30),
|
||||
6: None,
|
||||
7: today + timedelta(days=30),
|
||||
}
|
||||
for n, due in dues.items():
|
||||
w.units[n] = make_unit(s, pa, f"{n:03d}", f"Skid {'ABCDEFG'[n - 1]}", due)
|
||||
pb = make_project(s, "L489", "BPX", customer="BPX", due=today + timedelta(days=9))
|
||||
w.projects["L489"] = pb
|
||||
w.units[52] = make_unit(s, pb, "052", "48in separator")
|
||||
pc = make_project(s, "L333", "Old closed job", status="closed")
|
||||
w.projects["L333"] = pc
|
||||
make_unit(s, pc, "001", "Closed unit")
|
||||
u = w.units
|
||||
wade = shop("wh.wade")
|
||||
|
||||
# unit 001, late: a running packet over target with ticket hours, a short with a past ETA
|
||||
run_pkt = start(shop, to_station(shop, ready_skipped(shop, u[1], title="Running spool")))
|
||||
w.k["k_run"] = run_pkt["id"]
|
||||
run(
|
||||
s,
|
||||
"UPDATE packet_stages SET clock_elapsed_ms = %s WHERE packet_id = %s AND closed_at IS NULL",
|
||||
(4 * 3600 * 1000, run_pkt["id"]),
|
||||
)
|
||||
set_running_since(s, run_pkt["id"], 2 * 3600)
|
||||
labor_rows(s, run_pkt["id"], [("L670001", 10, "1.5"), ("L670002", 20, "1.7")])
|
||||
|
||||
short = to_warehouse(shop, draft(shop, u[1], title="Short spool"))
|
||||
yesterday = (today - timedelta(days=1)).isoformat()
|
||||
short = ok(
|
||||
wade.post(
|
||||
f"/api/packets/{short['id']}/bom/1/short",
|
||||
json={"qty": 1, "reason": "On order", "ref": "PO-77", "eta": yesterday, "expect_open": False},
|
||||
)
|
||||
)
|
||||
w.k["k_short"] = short["id"]
|
||||
|
||||
# unit 002, at risk by the due rule: a packet back in review after a Q.C. reject, a packet at Q.C.
|
||||
rev = at_qc(shop, u[2], title="Rejected spool")
|
||||
rev = ok(reject(shop, rev, REJECT_REASON, ncr=REJECT_NCR))
|
||||
assert rev["status"] == "review", rev["status"]
|
||||
w.k["k_rev"] = rev["id"]
|
||||
w.k["k_qc"] = at_qc(shop, u[2], title="Inspected spool")["id"]
|
||||
|
||||
# unit 003, at risk by a hold: a warehouse hold five days old, a packet waiting
|
||||
hold = to_warehouse(shop, draft(shop, u[3], title="Held spool"))
|
||||
ok(
|
||||
wade.post(
|
||||
f"/api/packets/{hold['id']}/wh",
|
||||
json={"stage": "hold", "reason": HOLD_REASON, "note": HOLD_NOTE, "expect_stage": "incoming"},
|
||||
)
|
||||
)
|
||||
run(
|
||||
s,
|
||||
"UPDATE packet_wh SET at = now() - interval '5 days' WHERE packet_id = %s AND closed_at IS NULL",
|
||||
(hold["id"],),
|
||||
)
|
||||
w.k["k_hold"] = hold["id"]
|
||||
w.k["k_inc"] = to_warehouse(shop, draft(shop, u[3], title="Waiting spool"))["id"]
|
||||
|
||||
# unit 004, at risk by a change order: CO-1 open three days, a packet pulled
|
||||
co = issue_co(shop, ready_skipped(shop, u[4], title="Changed spool"), ["03"])
|
||||
run(s, "UPDATE change_orders SET issued_at = now() - interval '3 days' WHERE packet_id = %s", (co["id"],))
|
||||
w.k["k_co"] = co["id"]
|
||||
pulled = pull_all(shop, to_warehouse(shop, draft(shop, u[4], title="Pulled spool")))
|
||||
ok(wade.post(f"/api/packets/{pulled['id']}/wh", json={"stage": "pulled", "expect_stage": "pulling"}))
|
||||
w.k["k_pulled"] = pulled["id"]
|
||||
|
||||
# unit 005, done: one filed now (created ten days ago), one filed 120 days ago with a synthetic history
|
||||
fp = filed(shop, u[5])
|
||||
run(s, "UPDATE packets SET created_at = now() - interval '10 days' WHERE id = %s", (fp["id"],))
|
||||
w.k["k_filed"] = fp["id"]
|
||||
old = draft(shop, u[5], title="Old filed spool")
|
||||
oid = old["id"]
|
||||
run(
|
||||
s,
|
||||
"UPDATE packets SET status = 'filed', status_at = now() - interval '120 days',"
|
||||
" created_at = now() - interval '140 days' WHERE id = %s",
|
||||
(oid,),
|
||||
)
|
||||
for days, action, frm, to, reason in (
|
||||
(139, "send_to_warehouse", "draft", "warehouse", None),
|
||||
(138, "wh_send_back", "warehouse", "draft", "Wrong BOM"),
|
||||
(137, "send_to_warehouse", "draft", "warehouse", None),
|
||||
(130, "wh_release", "warehouse", "ready", None),
|
||||
(126, "send_to_qc", "review", "qc", None),
|
||||
(125, "qc_reject", "qc", "review", "Porosity at W2"),
|
||||
(124, "send_to_qc", "review", "qc", None),
|
||||
(123, "qc_accept", "qc", "complete", None),
|
||||
(120, "file", "complete", "filed", None),
|
||||
):
|
||||
transition_at(s, oid, days, action, frm, to, reason=reason)
|
||||
qc_history_at(s, oid, 125, "rejected", "Porosity at W2")
|
||||
qc_history_at(s, oid, 123, "accepted")
|
||||
w.k["k_oldfiled"] = oid
|
||||
|
||||
# unit 006, on track: a draft (sent back twice long ago), ready, assigned, complete
|
||||
dr = draft(shop, u[6], title="Draft spool")
|
||||
w.k["k_draft"] = dr["id"]
|
||||
transition_at(s, dr["id"], 400, "return_to_clerical", "ready", "draft", by="sup.wes", reason="Wrong department")
|
||||
transition_at(s, dr["id"], 45, "return_to_clerical", "ready", "draft", by="sup.wes", reason="Missing drawing")
|
||||
w.k["k_ready"] = ready_skipped(shop, u[6], title="Ready spool")["id"]
|
||||
w.k["k_asg"] = to_station(shop, ready_skipped(shop, u[6], title="Assigned spool"), station="Stall 2")["id"]
|
||||
w.k["k_complete"] = at_complete(shop, u[6])["id"]
|
||||
return w
|
||||
|
||||
|
||||
def by_key(rows: list[dict[str, Any]], key: str = "key") -> dict[str, dict[str, Any]]:
|
||||
return {str(r[key]): r for r in rows}
|
||||
|
||||
|
||||
def table(answer: dict[str, Any], key: str) -> dict[str, Any]:
|
||||
found = [t for t in answer["tables"] if t["key"] == key]
|
||||
assert found, (key, [t["key"] for t in answer["tables"]])
|
||||
out: dict[str, Any] = found[0]
|
||||
return out
|
||||
|
||||
|
||||
def rows_by_pid(t: dict[str, Any]) -> dict[int, dict[str, Any]]:
|
||||
return {int(r["pid"]): r for r in t["rows"] if r.get("pid") is not None}
|
||||
|
||||
|
||||
def unit_rows(t: dict[str, Any]) -> dict[int, dict[str, Any]]:
|
||||
return {int(r["unit_id"]): r for r in t["rows"] if r.get("lvl") == 1 and r.get("unit_id") is not None}
|
||||
|
||||
|
||||
def project_rows(t: dict[str, Any]) -> dict[int, dict[str, Any]]:
|
||||
return {int(r["project_id"]): r for r in t["rows"] if r.get("lvl") == 0 and r.get("project_id") is not None}
|
||||
@@ -14,6 +14,11 @@ def _sub(hub: events.EventHub, name: str, screen: str | None = None) -> events.S
|
||||
return hub.subscribe(session_id=uuid.uuid4(), user_id=uuid.uuid4(), display_name=name, screen=screen)
|
||||
|
||||
|
||||
def _bare(data: dict) -> dict:
|
||||
"""A presence payload without the `since` times (they are the subscribe instants)."""
|
||||
return {"users": [{k: v for k, v in u.items() if k != "since"} for u in data["users"]]}
|
||||
|
||||
|
||||
async def _drain(q: asyncio.Queue[events.Item]) -> list[events.Item]:
|
||||
await asyncio.sleep(0) # let call_soon_threadsafe callbacks run
|
||||
out = []
|
||||
@@ -29,12 +34,13 @@ def test_fan_out_presence_and_session_targeting() -> None:
|
||||
b = _sub(hub, "Bo", "stall")
|
||||
got_a = await _drain(a.queue)
|
||||
assert [n for n, _ in got_a] == ["presence", "presence"]
|
||||
assert got_a[-1][1] == {
|
||||
assert _bare(got_a[-1][1]) == {
|
||||
"users": [
|
||||
{"user_id": a.user_id, "display_name": "Ann", "screen": "admin", "packet_id": None},
|
||||
{"user_id": b.user_id, "display_name": "Bo", "screen": "stall", "packet_id": None},
|
||||
{"user_id": a.user_id, "display_name": "Ann", "role": "", "screen": "admin", "packet_id": None},
|
||||
{"user_id": b.user_id, "display_name": "Bo", "role": "", "screen": "stall", "packet_id": None},
|
||||
]
|
||||
}
|
||||
assert all(u["since"] for u in got_a[-1][1]["users"])
|
||||
await _drain(b.queue)
|
||||
|
||||
hub._deliver(json.dumps({"event": "changed", "collection": "packets", "id": 7}))
|
||||
@@ -46,14 +52,18 @@ def test_fan_out_presence_and_session_targeting() -> None:
|
||||
("changed", {"collection": "projects"}),
|
||||
]
|
||||
|
||||
# A revoked session drops out of presence at once; the event itself goes to that session only.
|
||||
hub._deliver(json.dumps({"event": "session", "session_id": b.session_id, "action": "revoked"}))
|
||||
assert await _drain(a.queue) == []
|
||||
assert await _drain(b.queue) == [("session", {"session_id": b.session_id, "action": "revoked"})]
|
||||
users = [{"user_id": a.user_id, "display_name": "Ann", "role": "", "screen": "admin", "packet_id": None}]
|
||||
got = await _drain(a.queue)
|
||||
assert [(n, _bare(d)) for n, d in got] == [("presence", {"users": users})]
|
||||
got_b = await _drain(b.queue)
|
||||
assert got_b[0] == ("session", {"session_id": b.session_id, "action": "revoked"})
|
||||
assert [n for n, _ in got_b] == ["session", "presence"]
|
||||
|
||||
hub.unsubscribe(b)
|
||||
got = await _drain(a.queue)
|
||||
users = [{"user_id": a.user_id, "display_name": "Ann", "screen": "admin", "packet_id": None}]
|
||||
assert got == [("presence", {"users": users})]
|
||||
assert [(n, _bare(d)) for n, d in got] == [("presence", {"users": users})]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
"""Phase 8: the find bar filters applied by the server: status words, department, station, worker, the quick flags
|
||||
(Mine per screen), the flag kinds, the sorts and the facets, on `GET /api/packets` and on the boards.
|
||||
|
||||
The world is built once per module. Most statuses past `ready` are set by SQL: the filters read the packet row only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pmhelpers import get_ok, make_project, make_unit, module_shop, quick_draft, today_site
|
||||
from pshelpers import needs_pg
|
||||
from psycopg.types.json import Jsonb
|
||||
from shophelpers import Shop, issue_co, ok, run, step
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
DRAFTS = ("d1", "d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9")
|
||||
STATUS_ORDER = ("draft", "warehouse", "ready", "assigned", "in_progress", "review", "qc", "complete", "filed")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterWorld:
|
||||
shop: Shop
|
||||
k: dict[str, int] = field(default_factory=dict)
|
||||
projects: dict[str, int] = field(default_factory=dict)
|
||||
units: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
def ids(self, *keys: str) -> set[int]:
|
||||
return {self.k[x] for x in keys}
|
||||
|
||||
@property
|
||||
def every(self) -> set[int]:
|
||||
return set(self.k.values())
|
||||
|
||||
def but(self, *keys: str) -> set[int]:
|
||||
return self.every - self.ids(*keys)
|
||||
|
||||
|
||||
def title(key: str) -> str:
|
||||
return f"ZX{key} spool"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def world(tmp_path_factory: pytest.TempPathFactory) -> Iterator[FilterWorld]:
|
||||
with module_shop(tmp_path_factory.mktemp("filters")) as shop:
|
||||
s = shop.settings
|
||||
today = today_site()
|
||||
w = FilterWorld(shop)
|
||||
fa = make_project(s, "L700", "Filter project", customer="Acme", due=today + timedelta(days=60))
|
||||
fb = make_project(s, "L60", "Second project")
|
||||
w.projects = {"fa": fa, "fb": fb}
|
||||
w.units = {
|
||||
"f1": make_unit(s, fa, "001", due=today - timedelta(days=1)), # overdue
|
||||
"f2": make_unit(s, fa, "002", due=today + timedelta(days=3)), # due within 7 days
|
||||
"f3": make_unit(s, fa, "003", due=today + timedelta(days=30)),
|
||||
"g1": make_unit(s, fb, "001"), # no due date anywhere
|
||||
}
|
||||
|
||||
def mk(key: str, unit: str, send: str | None = None, **over: Any) -> dict[str, Any]:
|
||||
pkt = quick_draft(shop, w.units[unit], title(key), n=len(w.k) + 1, send=send, **over)
|
||||
w.k[key] = int(pkt["id"])
|
||||
return pkt
|
||||
|
||||
# drafts: one hand draft, VISUAL drafts with each flag kind, an unconfirmed rev and a clean one. The rev
|
||||
# confirmation lives in visual->>'rev_confirmed' (the drawing job writes it); revOk is the old name.
|
||||
mk("d1", "f3")
|
||||
visual = {
|
||||
"d2": (["rev_mismatch"], "rev_mismatch", {}),
|
||||
"d3": (["not_found"], "not_found", {}),
|
||||
"d4": ([], "attached", {}),
|
||||
"d5": (["lookup_failed"], "lookup_failed", {}),
|
||||
"d6": (["drawing_needed"], "needed", {}),
|
||||
"d7": (["not_released"], "not_released", {}),
|
||||
"d8": (["hand_drawing"], "hand", {"revOk": True, "rev_confirmed": True}),
|
||||
"d9": ([], "attached", {"revOk": True, "rev_confirmed": True}),
|
||||
}
|
||||
for key, (flags, state, vis) in visual.items():
|
||||
mk(key, "f3")
|
||||
run(
|
||||
s,
|
||||
"UPDATE packets SET source_kind = 'visual', flags = %s, drawing_state = %s::drawing_state,"
|
||||
" visual = %s WHERE id = %s",
|
||||
(flags, state, Jsonb(vis), w.k[key]),
|
||||
)
|
||||
# the warehouse: one waiting (overdue unit), one with an open short (unit due this week)
|
||||
mk("w1", "f1", send="warehouse")
|
||||
w2 = mk("w2", "f2", send="warehouse", bom=[{"item": 1, "qty": 2, "part_id": "PIPE-4", "description": "Pipe"}])
|
||||
ok(
|
||||
shop("wh.wade").post(
|
||||
f"/api/packets/{w2['id']}/bom/1/short", json={"qty": 1, "reason": "On order", "expect_open": False}
|
||||
)
|
||||
)
|
||||
# the shop: ready with an open change order, two at stations, one ready in 06 on the second project
|
||||
issue_co(shop, mk("r1", "f3", send="03"), ["03"])
|
||||
a1 = mk("a1", "f1", send="03")
|
||||
ok(step(shop("sup.wes"), a1, "assign", station_id=shop.stall("03", "Stall 1"), welder_id=shop.uid("weld.andy")))
|
||||
a2 = mk("a2", "f3", send="03")
|
||||
a2 = ok(
|
||||
step(shop("sup.wes"), a2, "assign", station_id=shop.stall("03", "Stall 2"), welder_id=shop.uid("dup.dave"))
|
||||
)
|
||||
ok(step(shop("dup.dave"), a2, "start"))
|
||||
mk("s6", "g1", send="06")
|
||||
# later statuses by SQL
|
||||
for key, unit, status, routed in (
|
||||
("rv1", "f2", "review", None),
|
||||
("q1", "f3", "qc", None),
|
||||
("c1", "f3", "complete", "databook"),
|
||||
("c2", "f3", "complete", "shipping"),
|
||||
("fl1", "f3", "filed", "databook"),
|
||||
):
|
||||
mk(key, unit, send="03")
|
||||
run(
|
||||
s,
|
||||
"UPDATE packets SET status = %s::packet_status, routed_to = %s::route_kind, status_at = now()"
|
||||
" WHERE id = %s",
|
||||
(status, routed, w.k[key]),
|
||||
)
|
||||
yield w
|
||||
|
||||
|
||||
def cards(w: FilterWorld, sam: str = "admin.ann", **params: Any) -> list[dict[str, Any]]:
|
||||
out = get_ok(w.shop(sam), "/api/packets", **params)
|
||||
got: list[dict[str, Any]] = out["packets"]
|
||||
return got
|
||||
|
||||
|
||||
def ids(w: FilterWorld, sam: str = "admin.ann", **params: Any) -> set[int]:
|
||||
got = [int(c["id"]) for c in cards(w, sam, **params)]
|
||||
assert len(got) == len(set(got)), got
|
||||
return set(got)
|
||||
|
||||
|
||||
# ================================================================ status, department, station, worker
|
||||
|
||||
|
||||
STATUS_WORDS = {
|
||||
"draft": DRAFTS,
|
||||
"warehouse": ("w1", "w2"),
|
||||
"ready": ("r1", "s6"),
|
||||
"shop": ("r1", "s6", "a1", "a2", "rv1", "q1"),
|
||||
"station": ("a1", "a2"),
|
||||
"review": ("rv1",),
|
||||
"qc": ("q1",),
|
||||
"complete": ("c1", "c2"),
|
||||
"filed": ("fl1",),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("word", [*STATUS_WORDS, "open"])
|
||||
def test_status_words(world: FilterWorld, word: str) -> None:
|
||||
want = world.but("fl1") if word == "open" else world.ids(*STATUS_WORDS[word])
|
||||
assert ids(world, status=word) == want
|
||||
|
||||
|
||||
def test_raw_status_list_still_works(world: FilterWorld) -> None:
|
||||
assert ids(world, status="ready,review") == world.ids("r1", "s6", "rv1")
|
||||
assert ids(world, status="assigned") == world.ids("a1")
|
||||
assert ids(world, status="in_progress") == world.ids("a2")
|
||||
|
||||
|
||||
def test_dept_station_worker(world: FilterWorld) -> None:
|
||||
shop = world.shop
|
||||
assert ids(world, dept="06") == world.ids("s6")
|
||||
assert ids(world, dept="03") == world.but("s6")
|
||||
assert ids(world, station_id=shop.stall("03", "Stall 1")) == world.ids("a1")
|
||||
assert ids(world, station_id=shop.stall("03", "Stall 2")) == world.ids("a2")
|
||||
assert ids(world, welder_id=shop.uid("weld.andy")) == world.ids("a1")
|
||||
assert ids(world, welder_id=shop.uid("dup.dave")) == world.ids("a2")
|
||||
assert ids(world, status="station", welder_id=shop.uid("dup.dave")) == world.ids("a2")
|
||||
assert ids(world, status="draft", welder_id=shop.uid("dup.dave")) == set()
|
||||
|
||||
|
||||
# ================================================================ quick flags
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("flags", "keys"),
|
||||
[
|
||||
("co", ("r1",)),
|
||||
("short", ("w2",)),
|
||||
("overdue", ("w1", "a1")),
|
||||
("week", ("w1", "a1", "w2", "rv1")),
|
||||
("short,week", ("w2",)),
|
||||
("co,short", ()),
|
||||
],
|
||||
)
|
||||
def test_quick_flags(world: FilterWorld, flags: str, keys: tuple[str, ...]) -> None:
|
||||
assert ids(world, flags=flags) == world.ids(*keys)
|
||||
|
||||
|
||||
def test_mine_per_screen(world: FilterWorld) -> None:
|
||||
shop = world.shop
|
||||
clerical = world.ids(*DRAFTS, "c1")
|
||||
assert ids(world, "clerk.clara", flags="mine") == clerical # the first screen of the roles
|
||||
assert ids(world, "clerk.clara", flags="mine", mine_screen="clerical") == clerical
|
||||
assert ids(world, "wh.wade", flags="mine", mine_screen="warehouse") == world.ids("w1", "w2")
|
||||
assert ids(world, "sup.wes", flags="mine", mine_screen="supervisor") == world.but("s6")
|
||||
assert ids(world, "multi.mia", flags="mine", mine_screen="supervisor") == world.ids("s6")
|
||||
assert ids(world, "multi.mia", flags="mine") == clerical # Clerical comes first in the screen order
|
||||
stall1 = shop.stall("03", "Stall 1")
|
||||
assert ids(world, "weld.andy", flags="mine", mine_screen="stall", mine_station_id=stall1) == world.ids("a1")
|
||||
assert ids(world, "weld.andy", flags="mine", mine_screen="stall") == set() # no station picked
|
||||
assert ids(world, "qc.quinn", flags="mine", mine_screen="qc") == world.ids("q1")
|
||||
assert ids(world, "pm.paula", flags="mine") == world.every
|
||||
assert ids(world, "admin.ann", flags="mine", mine_screen="admin") == world.every
|
||||
# mine with another flag narrows further
|
||||
assert ids(world, "wh.wade", flags="mine,short", mine_screen="warehouse") == world.ids("w2")
|
||||
|
||||
|
||||
def test_mine_never_widens(world: FilterWorld) -> None:
|
||||
"""A screen the caller does not have gives nothing more than the caller's own screens would."""
|
||||
got = ids(world, "clerk.clara", flags="mine", mine_screen="qc")
|
||||
assert got in (set(), world.ids("q1"), world.ids(*DRAFTS, "c1"))
|
||||
|
||||
|
||||
# ================================================================ flag kinds
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("flag", "keys"),
|
||||
[
|
||||
("any", ("d1", "d2", "d3", "d5", "d6", "d7", "d8")),
|
||||
("1", ("d1", "d2", "d3", "d5", "d6", "d7", "d8")),
|
||||
("rev", ("d2",)),
|
||||
("rev_mismatch", ("d2",)),
|
||||
("unconf", ("d4",)),
|
||||
("hand", ("d1", "d8")),
|
||||
("hand_upload", ("d8",)),
|
||||
("none", ("d7",)),
|
||||
("vault", ("d3",)),
|
||||
("read", ("d5",)),
|
||||
("wait", ("d6",)),
|
||||
],
|
||||
)
|
||||
def test_flag_kinds(world: FilterWorld, flag: str, keys: tuple[str, ...]) -> None:
|
||||
assert ids(world, flag=flag) == world.ids(*keys)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", ["no", "0"])
|
||||
def test_not_flagged(world: FilterWorld, flag: str) -> None:
|
||||
assert ids(world, flag=flag) == world.but("d1", "d2", "d3", "d5", "d6", "d7", "d8")
|
||||
|
||||
|
||||
# ================================================================ sorts
|
||||
|
||||
|
||||
def test_sort_updated_is_the_default(world: FilterWorld) -> None:
|
||||
for got in (cards(world), cards(world, sort="updated")):
|
||||
upd = [c["updated_at"] for c in got]
|
||||
assert upd == sorted(upd, reverse=True)
|
||||
assert [c["id"] for c in cards(world)] == [c["id"] for c in cards(world, sort="updated")]
|
||||
|
||||
|
||||
def test_sort_project(world: FilterWorld) -> None:
|
||||
got = cards(world, sort="project")
|
||||
keys = [(c["project"]["number"].encode(), c["unit"]["number"].encode(), c["op"] or "") for c in got]
|
||||
assert keys == sorted(keys) # C collation: byte order
|
||||
assert got[0]["project"]["number"] == "L60" # "L60" sorts before "L700"
|
||||
|
||||
|
||||
def test_sort_due(world: FilterWorld) -> None:
|
||||
got = cards(world, sort="due")
|
||||
due = [c["due_effective"] for c in got]
|
||||
assert due == sorted(due, key=lambda d: (d is None, d or ""))
|
||||
assert got[-1]["id"] == world.k["s6"] # no due date: last
|
||||
assert {got[0]["id"], got[1]["id"]} == world.ids("w1", "a1") # the overdue unit first
|
||||
|
||||
|
||||
def test_sort_status(world: FilterWorld) -> None:
|
||||
order = [STATUS_ORDER.index(c["status"]) for c in cards(world, sort="status")]
|
||||
assert order == sorted(order)
|
||||
|
||||
|
||||
def test_sort_dept(world: FilterWorld) -> None:
|
||||
got = cards(world, sort="dept")
|
||||
depts = [c["dept"]["code"] for c in got]
|
||||
assert depts == sorted(depts)
|
||||
in03 = [c["updated_at"] for c in got if c["dept"]["code"] == "03"]
|
||||
assert in03 == sorted(in03, reverse=True)
|
||||
|
||||
|
||||
def test_sort_unknown_is_refused_or_default(world: FilterWorld) -> None:
|
||||
r = world.shop("admin.ann").get("/api/packets", params={"sort": "drop table"})
|
||||
assert r.status_code in (200, 400, 422), r.text
|
||||
if r.status_code == 200:
|
||||
assert [c["id"] for c in r.json()["packets"]] == [c["id"] for c in cards(world)]
|
||||
|
||||
|
||||
# ================================================================ facets
|
||||
|
||||
|
||||
def test_facets_follow_the_project_and_unit_only(world: FilterWorld) -> None:
|
||||
shop = world.shop
|
||||
plain = get_ok(shop("admin.ann"), "/api/packets", project_id=world.projects["fa"])
|
||||
assert "facets" not in plain
|
||||
out = get_ok(shop("admin.ann"), "/api/packets", project_id=world.projects["fa"], facets=1)
|
||||
f = out["facets"]
|
||||
assert [(x["name"], x["dept_code"]) for x in f["stations"]] == [("Stall 1", "03"), ("Stall 2", "03")]
|
||||
assert {x["id"] for x in f["stations"]} == {shop.stall("03", "Stall 1"), shop.stall("03", "Stall 2")}
|
||||
assert [x["display_name"] for x in f["workers"]] == ["Andy Welder", "Dave Dup"]
|
||||
assert {x["id"] for x in f["workers"]} == {shop.uid("weld.andy"), shop.uid("dup.dave")}
|
||||
# other filters do not narrow the facets
|
||||
narrowed = get_ok(shop("admin.ann"), "/api/packets", project_id=world.projects["fa"], status="draft", facets=1)
|
||||
assert narrowed["packets"] and all(c["status"] == "draft" for c in narrowed["packets"])
|
||||
assert narrowed["facets"] == f
|
||||
unit = get_ok(shop("admin.ann"), "/api/packets", unit_id=world.units["f1"], facets=1)["facets"]
|
||||
assert [x["name"] for x in unit["stations"]] == ["Stall 1"]
|
||||
assert [x["display_name"] for x in unit["workers"]] == ["Andy Welder"]
|
||||
other = get_ok(shop("admin.ann"), "/api/packets", project_id=world.projects["fb"], facets=1)["facets"]
|
||||
assert other == {"stations": [], "workers": []}
|
||||
|
||||
|
||||
# ================================================================ the boards
|
||||
|
||||
|
||||
def board_ids(b: dict[str, Any]) -> set[int]:
|
||||
out = {int(c["id"]) for col in b["columns"].values() for c in col}
|
||||
for d in b.get("depts", []):
|
||||
for st in d["stations"]:
|
||||
out |= {int(c["id"]) for c in st["packets"]}
|
||||
return out
|
||||
|
||||
|
||||
def steady(k: dict[str, Any]) -> dict[str, Any]:
|
||||
return {key: v for key, v in k.items() if key != "elapsed_ms"}
|
||||
|
||||
|
||||
def test_shop_board_filters_keep_the_kpis(world: FilterWorld) -> None:
|
||||
shop = world.shop
|
||||
wes = shop("sup.wes")
|
||||
base = get_ok(wes, "/api/shop/board")
|
||||
assert "facets" in base and set(base["facets"]) == {"stations", "workers"}
|
||||
assert {"Stall 1", "Stall 2"} <= {x["name"] for x in base["facets"]["stations"]}
|
||||
assert world.ids("r1", "a1", "a2", "rv1", "q1") <= board_ids(base)
|
||||
cases: list[tuple[dict[str, Any], set[int]]] = [
|
||||
({"station_id": shop.stall("03", "Stall 1")}, world.ids("a1")),
|
||||
({"welder_id": shop.uid("dup.dave")}, world.ids("a2")),
|
||||
({"q": "ZXa2"}, world.ids("a2")),
|
||||
({"status": "review"}, world.ids("rv1")),
|
||||
({"status": "station"}, world.ids("a1", "a2")),
|
||||
({"flags": "co"}, world.ids("r1")),
|
||||
({"flags": "week"}, world.ids("w1", "a1", "w2", "rv1")), # the warehouse column holds w1 and w2
|
||||
({"q": "nothing-matches-this"}, set()),
|
||||
]
|
||||
for params, want in cases:
|
||||
b = get_ok(wes, "/api/shop/board", **params)
|
||||
assert board_ids(b) == want, params
|
||||
# the KPIs follow the project and unit only (elapsed_ms is a running clock, so it moves between calls)
|
||||
assert steady(b["kpis"]) == steady(base["kpis"]), params
|
||||
assert "facets" in b
|
||||
|
||||
|
||||
def test_stall_board_filters(world: FilterWorld) -> None:
|
||||
shop = world.shop
|
||||
andy = shop("weld.andy")
|
||||
stall1 = shop.stall("03", "Stall 1")
|
||||
base = get_ok(andy, "/api/shop/stall", station_id=stall1)
|
||||
assert {c["id"] for c in base["mine"]} == world.ids("a1") and "facets" in base
|
||||
assert get_ok(andy, "/api/shop/stall", station_id=stall1, q="ZXa1")["mine"][0]["id"] == world.k["a1"]
|
||||
none = get_ok(andy, "/api/shop/stall", station_id=stall1, q="nothing-matches-this")
|
||||
assert none["mine"] == [] and none["submitted"] == []
|
||||
assert get_ok(andy, "/api/shop/stall", station_id=stall1, status="review")["mine"] == []
|
||||
|
||||
|
||||
def test_warehouse_board_filters_keep_the_kpis(world: FilterWorld) -> None:
|
||||
wade = world.shop("wh.wade")
|
||||
base = get_ok(wade, "/api/warehouse/board")
|
||||
assert world.ids("w1", "w2") <= board_ids(base) and "facets" in base
|
||||
for params, want in (
|
||||
({"flags": "short"}, world.ids("w2")),
|
||||
({"q": "ZXw1"}, world.ids("w1")),
|
||||
({"flags": "overdue"}, world.ids("w1")),
|
||||
({"flags": "mine"}, world.ids("w1", "w2")),
|
||||
):
|
||||
b = get_ok(wade, "/api/warehouse/board", **params)
|
||||
assert board_ids(b) & world.ids("w1", "w2") == want, params
|
||||
assert b["kpis"] == base["kpis"], params
|
||||
|
||||
|
||||
def test_qc_home_filters_keep_the_kpis(world: FilterWorld) -> None:
|
||||
quinn = world.shop("qc.quinn")
|
||||
base = get_ok(quinn, "/api/qc/home")
|
||||
assert [c["id"] for c in base["columns"]["ready"]] == [world.k["q1"]] and "facets" in base
|
||||
found = get_ok(quinn, "/api/qc/home", q="ZXq1")
|
||||
assert [c["id"] for c in found["columns"]["ready"]] == [world.k["q1"]]
|
||||
none = get_ok(quinn, "/api/qc/home", q="nothing-matches-this")
|
||||
assert none["columns"]["ready"] == []
|
||||
assert none["kpis"] == base["kpis"] and found["kpis"] == base["kpis"]
|
||||
other = get_ok(quinn, "/api/qc/home", project_id=world.projects["fb"])
|
||||
assert other["columns"]["ready"] == []
|
||||
|
||||
|
||||
def test_filters_never_widen_access(world: FilterWorld) -> None:
|
||||
"""A board still refuses who it refused; a filter cannot open another department's board."""
|
||||
r = world.shop("multi.mia").get("/api/shop/board", params={"dept": "03", "status": "open"})
|
||||
assert r.status_code == 403, r.text
|
||||
r = world.shop("weld.andy").get("/api/shop/board", params={"flags": "mine"})
|
||||
assert r.status_code == 403, r.text
|
||||
@@ -8,9 +8,13 @@ stamp per mode. Nothing here reads VISUAL or M-Files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -31,7 +35,8 @@ from qchelpers import g
|
||||
from shophelpers import Shop, open_shop, run
|
||||
|
||||
from packetstation.pdf import databook, stamps
|
||||
from packetstation.pdf.style import clean
|
||||
from packetstation.pdf.doc import PAD_H, Doc
|
||||
from packetstation.pdf.style import L, clean, fmt_clock_hhmm, fmt_clock_hms, round_half_up
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
@@ -40,6 +45,7 @@ NOW = datetime(2026, 10, 17, 21, 4, tzinfo=UTC)
|
||||
SEAL = {"hash": "ab12cd34ef56" + "0" * 52, "key_id": "k1234567", "sealed_at": NOW}
|
||||
SECTIONS = [1, 2, 3, 4, 5, 6, 7]
|
||||
SIG_RE = re.compile(r"stamp ([0-9a-f]{16})")
|
||||
DUMP_DIR = os.environ.get("DATABOOK_DUMP_DIR") # off unless set
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -61,6 +67,17 @@ def build(shop: Shop, pid: int, mode: str = "preview", **kw: Any) -> Any:
|
||||
)
|
||||
|
||||
|
||||
def dump(name: str, built: Any, pkt: dict[str, Any]) -> None:
|
||||
"""With DATABOOK_DUMP_DIR set to a folder, keep the book and the packet read there (for the side by side
|
||||
comparison with the old build's data book). Off by default."""
|
||||
out = DUMP_DIR
|
||||
if not out:
|
||||
return
|
||||
Path(out).mkdir(parents=True, exist_ok=True)
|
||||
(Path(out) / f"{name}.pdf").write_bytes(built.bytes)
|
||||
(Path(out) / f"{name}.json").write_text(json.dumps({"packet": pkt, "index": built.index}, default=str, indent=1))
|
||||
|
||||
|
||||
def index(built: Any) -> list[dict[str, Any]]:
|
||||
keys = ("section", "doc", "ref", "rev", "page", "warn", "count")
|
||||
return [{k: g(r, k) for k in keys} for r in built.index]
|
||||
@@ -74,6 +91,7 @@ def test_data_book_preview(shop: Shop) -> None:
|
||||
pkt = rich_complete(shop)
|
||||
pid = pkt["id"]
|
||||
built = build(shop, pid)
|
||||
dump("preview", built, pkt)
|
||||
data: bytes = built.bytes
|
||||
assert data[:5] == b"%PDF-"
|
||||
reader = pdf_reader(data)
|
||||
@@ -141,6 +159,17 @@ def test_data_book_preview(shop: Shop) -> None:
|
||||
assert heat in trace, heat
|
||||
assert "open items" in trace.lower() and "item 3" in trace
|
||||
|
||||
# phase 8 comparison with the old build's book (each line one fix)
|
||||
assert '(9" x 11")' in index_text # a letter sheet is 8.5 in wide: Math.round gives 9
|
||||
assert re.search(r"\b\d\d:\d\d WELD TIME", flat(texts[0])), flat(texts[0]) # HH:MM as the old cover
|
||||
weld_page = next(r for r in rows if str(r["doc"]).startswith("Weld log"))["page"]
|
||||
assert re.search(r"Time recorded \d\d:\d\d:\d\d \(target 4\.5 h\)", flat(texts[int(weld_page) - 1]))
|
||||
qc_page = next(r for r in rows if r["section"] == 4)["page"]
|
||||
qc_lines = texts[int(qc_page) - 1].splitlines()
|
||||
assert "AM" not in qc_lines and "PM" not in qc_lines, qc_lines # the date and time stay on one line
|
||||
hist_page = next(r for r in rows if r["section"] == 7)["page"]
|
||||
assert "CO-1 " in flat(texts[int(hist_page) - 1]) and "documents changed" in flat(texts[int(hist_page) - 1])
|
||||
|
||||
# metadata
|
||||
meta = reader.metadata
|
||||
assert meta.title == f"{pkt['unit']['project']['number']} Lot 001 Manufacturing Data Book - Spool 7 outlet"
|
||||
@@ -199,3 +228,49 @@ def test_data_book_with_an_itp_document_has_no_itp_warning(shop: Shop) -> None:
|
||||
s5 = [r for r in rows if r["section"] == 5]
|
||||
assert s5[0]["doc"].startswith("ITP"), s5
|
||||
assert not any(r["doc"] == "No signed ITP filed for this unit" for r in s5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- phase 8: against the old build's book
|
||||
|
||||
|
||||
def test_co_summary_of_a_manual_change_without_sheets_reads_documents_changed() -> None:
|
||||
co: dict[str, Any] = {"origin": "manual", "review": "approved", "reason": "Nozzle moved", "items": []}
|
||||
assert databook.co_summary(co) == "documents changed" # the reason has its own column, as in coSummary()
|
||||
co["items"] = [{"old_dwg": "D-5001", "old_rev": "A", "new_rev": "B"}]
|
||||
assert databook.co_summary(co) == "D-5001 Rev A -> Rev B"
|
||||
|
||||
|
||||
def test_clock_and_sheet_size_forms_of_the_old_build() -> None:
|
||||
assert fmt_clock_hms(48_112) == "00:00:48" and fmt_clock_hms(3_725_000) == "01:02:05"
|
||||
assert fmt_clock_hhmm(48_112) == "00:00" and fmt_clock_hhmm(37_800_000) == "10:30"
|
||||
assert fmt_clock_hhmm(None) == "00:00"
|
||||
assert round_half_up(612 / 72) == 9 and round_half_up(792 / 72) == 11 and round_half_up(2448 / 72) == 34
|
||||
|
||||
|
||||
def _rows_height(rows: list[list[Any]], cols: dict[int, dict[str, Any]]) -> float:
|
||||
d = Doc(("T", "S"))
|
||||
return d.table(None, rows, 200.0, cols) - 200.0
|
||||
|
||||
|
||||
def test_table_cells_wrap_where_autotable_did() -> None:
|
||||
one_line = 8.3 * 1.15 + 2 * 4.2
|
||||
# 79.4 pt of text in an 86 pt column: one line in the old book (autotable's 1 pt slack, jsPDF's widths)
|
||||
assert abs(_rows_height([["10/18/2026 11:32 AM"]], {0: {"w": 86}}) - one_line) < 0.01
|
||||
# clearly too long still wraps
|
||||
assert _rows_height([["10/18/2026 11:32 AM and more"]], {0: {"w": 86}}) > one_line * 1.5
|
||||
|
||||
|
||||
def test_table_heads_sit_left_over_centred_columns() -> None:
|
||||
from pypdf import PdfReader
|
||||
|
||||
d = Doc(("T", "S"))
|
||||
d.table(["ITEM", "QTY", "TEXT"], [["1", "2", "x"]], 200.0, {0: {"w": 30, "align": "center"}, 1: {"w": 30}})
|
||||
xs: dict[str, float] = {}
|
||||
|
||||
def visit(text: str, cm: Any, tm: Any, font: Any, size: Any) -> None:
|
||||
if text.strip() in ("ITEM", "QTY"):
|
||||
xs[text.strip()] = float(tm[4])
|
||||
|
||||
PdfReader(io.BytesIO(d.finish())).pages[0].extract_text(visitor_text=visit)
|
||||
assert xs["ITEM"] == pytest.approx(L + PAD_H, abs=0.5), xs # autotable column styles style the body only
|
||||
assert xs["QTY"] == pytest.approx(L + 30 + PAD_H, abs=0.5), xs
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Phase 8 server smoke: one small world through every new read (the find bar on the list and the boards, the PM
|
||||
reports, the exports) so the server agent's shapes are exercised end to end. The detailed checks live in
|
||||
test_search.py, test_filters.py, test_pm_api.py, test_pm_export.py and test_presence.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from fakedir import FakeDirectory
|
||||
from pshelpers import app_conn, needs_pg
|
||||
from pypdf import PdfReader
|
||||
from shophelpers import Shop, draft, make_unit, ok, open_shop, ready_packet, run, set_running_since, start, to_station
|
||||
from shophelpers import to_warehouse as send_to_wh
|
||||
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||
yield from open_shop(pg_settings, fake_dir)
|
||||
|
||||
|
||||
def test_find_bar_boards_and_pm_reports(shop: Shop) -> None:
|
||||
settings = shop.settings
|
||||
project_id, u1 = make_unit(settings, "L670", "052")
|
||||
with app_conn(settings) as conn:
|
||||
u2 = conn.execute(
|
||||
"INSERT INTO units (project_id, lot, name, status, due) VALUES (%s, '053', 'Unit two', 'open',"
|
||||
" current_date - 2) RETURNING id",
|
||||
(project_id,),
|
||||
).fetchone()[0]
|
||||
conn.execute("UPDATE projects SET due = current_date + 30 WHERE id = %s", (project_id,))
|
||||
|
||||
p_draft = draft(shop, u1, title="Inlet nozzle =SUM(A1)")
|
||||
p_wh = send_to_wh(shop, draft(shop, u1, drawing="D-7000"))
|
||||
ok(
|
||||
shop("wh.wade").post(
|
||||
f"/api/packets/{p_wh['id']}/wh",
|
||||
json={"stage": "hold", "reason": "Waiting on purchasing / PO", "expect_stage": "incoming"},
|
||||
)
|
||||
)
|
||||
p_st = start(shop, to_station(shop, ready_packet(shop, u2)))
|
||||
set_running_since(settings, p_st["id"], 6 * 3600)
|
||||
with app_conn(settings) as conn:
|
||||
line = conn.execute("SELECT id FROM bom_lines WHERE packet_id = %s AND item = 2", (p_wh["id"],)).fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO bom_shorts (packet_id, bom_line_id, qty, qty_open, reason, ref, eta)"
|
||||
" VALUES (%s, %s, 2, 2, 'Not in stock', 'PO-881', current_date - 1)",
|
||||
(p_wh["id"], line),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO qc_history (packet_id, at, result, inspector, note, dept_code, snapshot)"
|
||||
" VALUES (%s, now() - interval '3 days', 'rejected', 'Quinn QC', 'NCR-12 · undercut', '03', '{}')",
|
||||
(p_st["id"],),
|
||||
)
|
||||
co = conn.execute(
|
||||
"INSERT INTO change_orders (packet_id, co_no, origin, review, ecn, reason, issued_at)"
|
||||
" VALUES (%s, 1, 'manual', 'approved', 'ECN-77', 'Rev C', now() - interval '4 days') RETURNING id",
|
||||
(p_st["id"],),
|
||||
).fetchone()[0]
|
||||
conn.execute("INSERT INTO co_required_depts (co_id, dept_code) VALUES (%s, '03')", (co,))
|
||||
run(settings, "UPDATE packets SET wps = 'WPS-99' WHERE id = %s", (p_draft["id"],))
|
||||
|
||||
sup = shop("sup.wes")
|
||||
# The find bar on the list
|
||||
got = ok(sup.get("/api/packets", params={"q": "lot 52", "project_id": project_id, "facets": 1}))
|
||||
ids = {c["id"] for c in got["packets"]}
|
||||
assert p_draft["id"] in ids and p_st["id"] not in ids, ids
|
||||
assert len(got["facets"]["workers"]) == 1 and len(got["facets"]["stations"]) == 1
|
||||
assert ok(sup.get("/api/packets", params={"q": "HT-20"}))["packets"][0]["id"] == p_st["id"]
|
||||
assert [c["id"] for c in ok(sup.get("/api/packets", params={"q": "ECN-77"}))["packets"]] == [p_st["id"]]
|
||||
assert [c["id"] for c in ok(sup.get("/api/packets", params={"status": "station"}))["packets"]] == [p_st["id"]]
|
||||
shorts = ok(sup.get("/api/packets", params={"flags": "short"}))["packets"]
|
||||
assert [c["id"] for c in shorts] == [p_wh["id"]]
|
||||
mine = ok(sup.get("/api/packets", params={"flags": "mine", "mine_screen": "supervisor"}))["packets"]
|
||||
assert {c["id"] for c in mine} == {p_draft["id"], p_wh["id"], p_st["id"]}
|
||||
over = ok(sup.get("/api/packets", params={"flags": "overdue"}))["packets"]
|
||||
assert [c["id"] for c in over] == [p_st["id"]]
|
||||
for sort in ("updated", "project", "due", "status", "dept"):
|
||||
assert len(ok(sup.get("/api/packets", params={"sort": sort, "project_id": project_id}))["packets"]) == 3
|
||||
hand = ok(sup.get("/api/packets", params={"flag": "hand"}))["packets"]
|
||||
assert {c["id"] for c in hand} == {p_draft["id"]}
|
||||
|
||||
# The boards keep their KPIs on the project scope
|
||||
b = ok(sup.get("/api/shop/board", params={"q": "nothing-matches-this"}))
|
||||
assert b["kpis"]["at_stations"] == 1 and not b["depts"][0]["stations"][0]["packets"]
|
||||
assert "facets" in b
|
||||
wh = ok(shop("wh.wade").get("/api/warehouse/board", params={"flags": "short"}))
|
||||
assert [c["id"] for c in wh["columns"]["hold"]] == [p_wh["id"]] and "facets" in wh
|
||||
qc = ok(shop("qc.quinn").get("/api/qc/home", params={"q": "zzz"}))
|
||||
assert qc["kpis"]["waiting"] == 0 and "facets" in qc
|
||||
stall = ok(shop("weld.andy").get("/api/shop/stall", params={"station_id": shop.stall(), "flags": "mine"}))
|
||||
assert [c["id"] for c in stall["mine"]] == [p_st["id"]]
|
||||
|
||||
# Project Management
|
||||
pmc = shop("pm.paula")
|
||||
ov = ok(pmc.get("/api/pm/overview", params={"period": "30"}))
|
||||
k = {x["key"]: x["n"] for x in ov["kpis"]}
|
||||
assert k["open"] == 3 and k["holds"] == 1 and k["shorts"] == 1 and k["cos"] == 1 and k["rejects"] == 1, k
|
||||
assert k["late_units"] == 1, k
|
||||
assert ov["scope"]["label"] == "All projects" and ov["summary"][0].startswith("3 open packets")
|
||||
assert sum(s["count"] for s in ov["pipeline"]) == 3
|
||||
assert [(r["cells"][0], r["health"]) for r in ov["attention"]["rows"]] == [("L670052", "risk"), ("L670053", "late")]
|
||||
pr = ok(pmc.get("/api/pm/projects", params={"expand": "all"}))
|
||||
assert [r["lvl"] for r in pr["tables"][0]["rows"]][:3] == [0, 1, 1]
|
||||
ag = ok(pmc.get("/api/pm/aging"))
|
||||
assert [t["key"] for t in ag["tables"]] == ["aging_steps", "aging_open", "aging_history"]
|
||||
hr = ok(pmc.get("/api/pm/hours"))
|
||||
over_rows = hr["tables"][3]["rows"]
|
||||
assert [r["pid"] for r in over_rows] == [p_st["id"]], hr
|
||||
ql = ok(pmc.get("/api/pm/quality"))
|
||||
assert [len(t["rows"]) for t in ql["tables"]][:4] == [1, 1, 1, 1], ql
|
||||
fd = ok(pmc.get("/api/pm/find", params={"sort": "project", "project_id": project_id}))
|
||||
assert fd["scope"]["label"] == "Project L670" and len(fd["packets"]) == 3
|
||||
|
||||
r = pmc.get("/api/pm/export.csv", params={"table": "all"})
|
||||
assert r.status_code == 200, r.text
|
||||
text = r.content.decode("utf-8")
|
||||
assert text.startswith("\ufeffPESCO Packet Station — All reports\r\n")
|
||||
assert "attachment" in r.headers["content-disposition"]
|
||||
assert pmc.get("/api/pm/export.csv", params={"table": "nope"}).status_code == 404
|
||||
r = pmc.get("/api/pm/report.pdf")
|
||||
assert r.status_code == 200 and r.content.startswith(b"%PDF")
|
||||
|
||||
reader = PdfReader(io.BytesIO(r.content))
|
||||
assert len(reader.pages) > 5
|
||||
assert "PRODUCTION STATUS REPORT" in reader.pages[0].extract_text()
|
||||
assert all("Controlled copy" in pg.extract_text() for pg in reader.pages)
|
||||
with app_conn(settings) as conn:
|
||||
kinds = [x[0] for x in conn.execute("SELECT kind FROM print_log ORDER BY id")]
|
||||
acts = [x[0] for x in conn.execute("SELECT action FROM audit_events WHERE action = 'report exported'")]
|
||||
assert kinds == ["report-csv", "report-pdf"] and len(acts) == 2
|
||||
assert shop("clerk.clara").get("/api/pm/overview").status_code == 200
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Phase 8: the Project Management read endpoints against the PM world (pmhelpers.py).
|
||||
|
||||
The world is built once for the module; every test only reads it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pmhelpers import (
|
||||
ATTENTION_HEAD,
|
||||
BY_PERIOD,
|
||||
FILED_KEYS,
|
||||
HEADS,
|
||||
HEALTH,
|
||||
HOLD_NOTE,
|
||||
HOLD_REASON,
|
||||
KPI_ALERT,
|
||||
KPI_KEYS,
|
||||
KPI_TABS,
|
||||
NOTES,
|
||||
OPEN_KEYS,
|
||||
PERIODS,
|
||||
PIPELINE_COUNTS,
|
||||
PM_STEPS,
|
||||
REJECT_NCR,
|
||||
REJECT_REASON,
|
||||
STEP_LABEL,
|
||||
STEP_OF,
|
||||
TAB_TABLES,
|
||||
UNIT_HEALTH,
|
||||
PmWorld,
|
||||
anon,
|
||||
by_key,
|
||||
get_ok,
|
||||
kpi_labels,
|
||||
module_shop,
|
||||
pm_world,
|
||||
project_rows,
|
||||
table,
|
||||
titles,
|
||||
unit_rows,
|
||||
)
|
||||
from pshelpers import needs_pg
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
ENDPOINTS = ("overview", "projects", "aging", "hours", "quality", "find")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def world(tmp_path_factory: pytest.TempPathFactory) -> Iterator[PmWorld]:
|
||||
with module_shop(tmp_path_factory.mktemp("pm_api")) as shop:
|
||||
yield pm_world(shop)
|
||||
|
||||
|
||||
def pm(w: PmWorld, what: str, sam: str = "pm.paula", **params: Any) -> dict[str, Any]:
|
||||
return get_ok(w.shop(sam), f"/api/pm/{what}", **params)
|
||||
|
||||
|
||||
def kpis(answer: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
return by_key(answer["kpis"])
|
||||
|
||||
|
||||
def ids(w: PmWorld, *keys: str) -> set[int]:
|
||||
return {w.k[k] for k in keys}
|
||||
|
||||
|
||||
# ================================================================ overview
|
||||
|
||||
|
||||
def test_overview_kpis(world: PmWorld) -> None:
|
||||
out = pm(world, "overview")
|
||||
assert [k["key"] for k in out["kpis"]] == list(KPI_KEYS)
|
||||
assert [k["label"] for k in out["kpis"]] == list(kpi_labels("90"))
|
||||
assert [k["tab"] for k in out["kpis"]] == list(KPI_TABS)
|
||||
assert {k["key"] for k in out["kpis"] if k["alert"]} == KPI_ALERT
|
||||
n = {k: v["n"] for k, v in kpis(out).items()}
|
||||
assert n == {
|
||||
"open": len(OPEN_KEYS),
|
||||
"late_units": 1,
|
||||
"risk_units": 3,
|
||||
"holds": 1,
|
||||
"cos": 1,
|
||||
"shorts": 1,
|
||||
"rejects": 1,
|
||||
"over": 1,
|
||||
"filed": 1,
|
||||
"lead": "10.0 d",
|
||||
}
|
||||
|
||||
|
||||
def test_overview_pipeline(world: PmWorld) -> None:
|
||||
out = pm(world, "overview")
|
||||
pipe = out["pipeline"]
|
||||
assert [(p["key"], p["label"]) for p in pipe] == list(PM_STEPS)
|
||||
assert {p["key"]: p["count"] for p in pipe} == PIPELINE_COUNTS
|
||||
slow = [p["key"] for p in pipe if p["slowest"]]
|
||||
assert slow == ["wh_hold"] # held five days; every other step is minutes old
|
||||
hold = by_key(pipe)["wh_hold"]
|
||||
assert hold["avg_s"] >= 5 * 86400 - 60 and hold["max_s"] >= hold["avg_s"]
|
||||
assert hold["oldest"]["pid"] == world.k["k_hold"] and hold["oldest"]["job"]
|
||||
empty = [p for p in pipe if p["count"] == 0]
|
||||
assert all(p["oldest"] is None for p in empty)
|
||||
|
||||
|
||||
def test_overview_summary(world: PmWorld) -> None:
|
||||
out = pm(world, "overview")
|
||||
lines = out["summary"]
|
||||
assert lines[0] == f"{len(OPEN_KEYS)} open packets in scope (All projects)."
|
||||
assert lines[1].startswith("Late: ") and world.unit_number(1) in lines[1]
|
||||
most = [x for x in lines if x.startswith("Most packets are at ")]
|
||||
assert most and most[0] in (
|
||||
f'Most packets are at "{STEP_LABEL["ready"]}" (2).',
|
||||
f'Most packets are at "{STEP_LABEL["station"]}" (2).',
|
||||
)
|
||||
slow = [x for x in lines if x.startswith("Slowest step right now: ")]
|
||||
assert slow and slow[0].startswith(f'Slowest step right now: "{STEP_LABEL["wh_hold"]}", averaging 5')
|
||||
assert "Needs attention: 1 warehouse hold, 1 open change order, 1 open short, 1 Q.C. reject." in lines
|
||||
assert "1 packet is over target hours." in lines
|
||||
|
||||
|
||||
def test_overview_attention(world: PmWorld) -> None:
|
||||
att = pm(world, "overview")["attention"]
|
||||
assert att["title"] == "Units late or at risk" and att["head"] == ATTENTION_HEAD
|
||||
got = {int(r["unit_id"]): r["health"] for r in att["rows"]}
|
||||
assert got == {world.units[n]: h for n, h in UNIT_HEALTH.items() if h in ("late", "risk")}
|
||||
for r in att["rows"]:
|
||||
assert r["cells"][-1] == HEALTH[r["health"]]
|
||||
late = next(r for r in att["rows"] if r["health"] == "late")
|
||||
assert late["cells"][0] == world.unit_number(1)
|
||||
assert late["cells"][3] == "1 d over"
|
||||
|
||||
|
||||
# ================================================================ health and projects
|
||||
|
||||
|
||||
def test_health_per_unit_and_project(world: PmWorld) -> None:
|
||||
out = pm(world, "projects", expand="all")
|
||||
t = table(out, "projects")
|
||||
assert t["title"] == titles()["projects"] and t["head"] == HEADS["projects"]
|
||||
assert t["note"] == NOTES["projects"]
|
||||
units = unit_rows(t)
|
||||
for n, health in UNIT_HEALTH.items():
|
||||
row = units[world.units[n]]
|
||||
assert row["health"] == health, (n, row)
|
||||
assert row["cells"][-1] == HEALTH[health]
|
||||
assert row["project_id"] == world.projects["L670"]
|
||||
assert units[world.units[5]]["cells"][12].startswith("100")
|
||||
assert units[world.units[7]]["cells"][6] == "0"
|
||||
projects = project_rows(t)
|
||||
assert projects[world.projects["L670"]]["health"] == "late" # as healthy as its worst unit
|
||||
assert projects[world.projects["L670"]]["cells"][-1] == "Late"
|
||||
assert projects[world.projects["L489"]]["health"] == "empty"
|
||||
assert world.projects["L333"] not in projects # closed, no packet in scope
|
||||
assert units[world.units[52]]["health"] == "empty"
|
||||
# the order: each project row, then its units
|
||||
lvl = [(r["lvl"], r["project_id"]) for r in t["rows"]]
|
||||
first = lvl.index((0, world.projects["L670"]))
|
||||
assert all(x == (1, world.projects["L670"]) for x in lvl[first + 1 : first + 8])
|
||||
|
||||
|
||||
def test_drill_down(world: PmWorld) -> None:
|
||||
pa, pb = world.projects["L670"], world.projects["L489"]
|
||||
closed = pm(world, "projects")
|
||||
t = table(closed, "projects")
|
||||
assert {r["lvl"] for r in t["rows"]} == {0}
|
||||
assert set(project_rows(t)) == {pa, pb}
|
||||
one = table(pm(world, "projects", expand=str(pb)), "projects")
|
||||
assert set(unit_rows(one)) == {world.units[52]}
|
||||
both = table(pm(world, "projects", expand=f"{pa},{pb}"), "projects")
|
||||
assert set(unit_rows(both)) == set(world.units.values())
|
||||
picked = table(pm(world, "projects", project_id=pa), "projects")
|
||||
assert set(project_rows(picked)) == {pa}
|
||||
assert set(unit_rows(picked)) == {world.units[n] for n in range(1, 8)}
|
||||
# any filter other than the project: only units with a packet in scope
|
||||
at_qc = table(pm(world, "projects", status="qc", expand="all"), "projects")
|
||||
assert set(unit_rows(at_qc)) == {world.units[2]}
|
||||
assert pa in project_rows(at_qc)
|
||||
|
||||
|
||||
# ================================================================ aging, hours, quality
|
||||
|
||||
|
||||
def test_aging(world: PmWorld) -> None:
|
||||
out = pm(world, "aging")
|
||||
assert [t["key"] for t in out["tables"]] == list(TAB_TABLES["aging"])
|
||||
assert [p["key"] for p in out["pipeline"]] == [k for k, _ in PM_STEPS]
|
||||
for key in TAB_TABLES["aging"]:
|
||||
t = table(out, key)
|
||||
assert t["title"] == titles()[key] and t["head"] == HEADS[key] and t["note"] == NOTES[key]
|
||||
steps = {r["cells"][0]: r for r in table(out, "aging_steps")["rows"]}
|
||||
assert steps[STEP_LABEL["wh_hold"]]["cells"][1] == "1"
|
||||
assert steps[STEP_LABEL["ready"]]["cells"][1] == "2"
|
||||
opened = table(out, "aging_open")
|
||||
assert {int(r["pid"]) for r in opened["rows"]} == ids(world, *OPEN_KEYS)
|
||||
assert opened["rows"][0]["pid"] == world.k["k_hold"] and opened["rows"][0]["flag"] is True
|
||||
assert opened["rows"][0]["cells"][2] == STEP_LABEL["wh_hold"]
|
||||
fresh_rows = [r for r in opened["rows"] if r["pid"] in ids(world, "k_ready", "k_asg", "k_qc")]
|
||||
assert fresh_rows and not any(r["flag"] for r in fresh_rows)
|
||||
steps_of = {int(r["pid"]): r["cells"][2] for r in opened["rows"]}
|
||||
for key in OPEN_KEYS:
|
||||
assert steps_of[world.k[key]] == STEP_LABEL[STEP_OF[key]], key
|
||||
assert table(out, "aging_history")["rows"], "the moves made by the world"
|
||||
|
||||
|
||||
def test_hours(world: PmWorld) -> None:
|
||||
out = pm(world, "hours")
|
||||
assert [t["key"] for t in out["tables"]] == list(TAB_TABLES["hours"])
|
||||
for key in TAB_TABLES["hours"]:
|
||||
t = table(out, key)
|
||||
assert t["title"] == titles()[key] and t["head"] == HEADS[key]
|
||||
assert table(out, "hours_over")["note"] == NOTES["hours_over"]
|
||||
dept = table(out, "hours_dept")["rows"]
|
||||
assert len(dept) == 1 and "Weld Parts" in dept[0]["cells"][0]
|
||||
# counted: the running packet, and every packet past review with target hours
|
||||
assert dept[0]["cells"][1] == "6"
|
||||
assert dept[0]["cells"][3] == "3.2" # ticket hours: the running packet's VISUAL rows only
|
||||
assert dept[0]["cells"][4] == "27.0" # 6 x 4.5
|
||||
assert dept[0]["cells"][7] == "1"
|
||||
over = table(out, "hours_over")["rows"]
|
||||
assert [r["pid"] for r in over] == [world.k["k_run"]]
|
||||
cells = over[0]["cells"]
|
||||
# 4 h stored plus the running span (2 h when the world was built, more by now)
|
||||
timer = float(cells[3])
|
||||
assert 6.0 <= timer < 8.0, cells
|
||||
assert cells[4] == "3.2" and cells[5] == "4.5" and cells[6] == f"+{timer - 4.5:.1f} h"
|
||||
assert "Andy Welder" in cells[1] or "Stall 1" in cells[1]
|
||||
workers = {r["cells"][0]: r for r in table(out, "hours_worker")["rows"]}
|
||||
assert set(workers) == {"Andy Welder", "—"}
|
||||
assert workers["Andy Welder"]["cells"][1] == "5" and workers["—"]["cells"][1] == "1"
|
||||
assert workers["Andy Welder"]["cells"][7] == "1"
|
||||
projects = table(out, "hours_project")["rows"]
|
||||
assert [r["cells"][0] for r in projects] == ["L670 Pump skids"]
|
||||
variance = [r["cells"][5] for r in table(out, "hours_worker")["rows"]]
|
||||
assert all(v.startswith(("+", "-", "0")) for v in variance)
|
||||
|
||||
|
||||
def test_quality(world: PmWorld) -> None:
|
||||
out = pm(world, "quality")
|
||||
assert [t["key"] for t in out["tables"]] == list(TAB_TABLES["quality"])
|
||||
for key in TAB_TABLES["quality"]:
|
||||
t = table(out, key)
|
||||
assert t["title"] == titles()[key] and t["head"] == HEADS[key]
|
||||
rej = table(out, "quality_rejects")["rows"]
|
||||
assert [r["pid"] for r in rej] == [world.k["k_rev"]]
|
||||
assert rej[0]["cells"][2] == "—" # the reject was made before an inspector name was entered
|
||||
assert REJECT_REASON in rej[0]["cells"][3] and REJECT_NCR in rej[0]["cells"][3]
|
||||
shorts = table(out, "quality_shorts")["rows"]
|
||||
assert [r["pid"] for r in shorts] == [world.k["k_short"]]
|
||||
assert shorts[0]["flag"] is True and shorts[0]["cells"][5].endswith(" (late)")
|
||||
assert shorts[0]["cells"][3] == "On order" and shorts[0]["cells"][4] == "PO-77"
|
||||
assert shorts[0]["cells"][1].startswith("1 ") and shorts[0]["cells"][7] == STEP_LABEL["wh_pulling"]
|
||||
cos = table(out, "quality_cos")["rows"]
|
||||
assert [r["pid"] for r in cos] == [world.k["k_co"]]
|
||||
assert cos[0]["cells"][1].startswith("CO-1") and cos[0]["flag"] is True # open three days
|
||||
assert cos[0]["cells"][4] == "3.0 d"
|
||||
holds = table(out, "quality_holds")["rows"]
|
||||
assert [r["pid"] for r in holds] == [world.k["k_hold"]] and holds[0]["flag"] is True
|
||||
assert holds[0]["cells"][1:4] == [HOLD_REASON, HOLD_NOTE, "Wade Warehouse"]
|
||||
assert holds[0]["cells"][4] == "5.0 d"
|
||||
back = table(out, "quality_sentback")["rows"]
|
||||
assert [r["pid"] for r in back] == [world.k["k_rev"], world.k["k_draft"]] # newest first
|
||||
assert back[1]["cells"][3] == "Wes Super" and back[1]["cells"][4] == "Missing drawing"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("period", ["30", "90", "365", "all"])
|
||||
def test_every_period(world: PmWorld, period: str) -> None:
|
||||
want = BY_PERIOD[period]
|
||||
ov = pm(world, "overview", period=period)
|
||||
sc = ov["scope"]["period"]
|
||||
assert sc["value"] == period and sc["label"] == PERIODS[period]
|
||||
assert (sc["start"] is None) == (period == "all")
|
||||
assert [k["label"] for k in ov["kpis"]] == list(kpi_labels(period))
|
||||
n = {k: v["n"] for k, v in kpis(ov).items()}
|
||||
assert n["rejects"] == want["rejects"] and n["filed"] == want["filed"]
|
||||
if "lead" in want:
|
||||
assert n["lead"] == want["lead"]
|
||||
q = pm(world, "quality", period=period)
|
||||
assert len(table(q, "quality_sentback")["rows"]) == want["sentback"]
|
||||
assert len(table(q, "quality_rejects")["rows"]) == want["rejects"]
|
||||
assert table(q, "quality_rejects")["title"] == titles(period)["quality_rejects"]
|
||||
# the open counts do not follow the period
|
||||
assert n["open"] == len(OPEN_KEYS) and n["holds"] == 1
|
||||
|
||||
|
||||
# ================================================================ find, scope, access
|
||||
|
||||
|
||||
def test_find_sorted(world: PmWorld) -> None:
|
||||
every = ids(world, *OPEN_KEYS, *FILED_KEYS)
|
||||
out = pm(world, "find", sort="project")
|
||||
got = [c["id"] for c in out["packets"]]
|
||||
assert set(got) == every and len(got) == len(every)
|
||||
units = [c["unit"]["number"] for c in out["packets"]]
|
||||
assert units == sorted(units)
|
||||
due = pm(world, "find", sort="due")["packets"]
|
||||
assert due[0]["unit"]["number"] == world.unit_number(1)
|
||||
late = [c["due_effective"] for c in due]
|
||||
assert late == sorted(late, key=lambda d: (d is None, d or ""))
|
||||
upd = [c["updated_at"] for c in pm(world, "find")["packets"]]
|
||||
assert upd == sorted(upd, reverse=True)
|
||||
scoped = pm(world, "find", unit_id=world.units[2])["packets"]
|
||||
assert {c["id"] for c in scoped} == ids(world, "k_rev", "k_qc")
|
||||
|
||||
|
||||
def test_scope_label(world: PmWorld) -> None:
|
||||
pa = world.projects["L670"]
|
||||
sc = pm(world, "overview")["scope"]
|
||||
assert sc["label"] == "All projects" and sc["project"] is None and sc["unit"] is None
|
||||
assert sc["count"] == len(pm(world, "find")["packets"])
|
||||
sc = pm(world, "overview", project_id=pa)["scope"]
|
||||
assert sc["label"] == "Project L670"
|
||||
assert sc["project"] == {"id": pa, "number": "L670", "name": "Pump skids"}
|
||||
sc = pm(world, "overview", project_id=pa, unit_id=world.units[1])["scope"]
|
||||
assert sc["label"].startswith("Unit L670001") and sc["unit"] == {"id": world.units[1], "number": "L670001"}
|
||||
assert sc["count"] == 2
|
||||
sc = pm(world, "overview", q="spool")["scope"]
|
||||
assert sc["label"] == 'All projects · search "spool"'
|
||||
sc = pm(world, "overview", project_id=pa, dept="03", q="held")["scope"]
|
||||
parts = sc["label"].split(" · ")
|
||||
assert parts[0] == "Project L670" and parts[-1] == 'search "held"' and len(parts) == 3
|
||||
assert sc["count"] == 1
|
||||
flags = pm(world, "overview", flags="short")["scope"]
|
||||
assert flags["label"].startswith("All projects · ") and flags["count"] == 1
|
||||
# mine is ignored on the PM screens
|
||||
assert pm(world, "overview", flags="mine")["scope"]["count"] == sc_count_all(world)
|
||||
|
||||
|
||||
def sc_count_all(world: PmWorld) -> int:
|
||||
return int(pm(world, "overview")["scope"]["count"])
|
||||
|
||||
|
||||
def test_scope_follows_the_project(world: PmWorld) -> None:
|
||||
pb = world.projects["L489"]
|
||||
out = pm(world, "overview", project_id=pb)
|
||||
n = {k: v["n"] for k, v in kpis(out).items()}
|
||||
assert n["open"] == 0 and n["holds"] == 0 and n["late_units"] == 0
|
||||
assert out["attention"]["rows"] == []
|
||||
q = pm(world, "quality", project_id=pb)
|
||||
assert all(table(q, k)["rows"] == [] for k in TAB_TABLES["quality"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sam", ["pm.paula", "clerk.clara", "weld.andy"])
|
||||
def test_every_signed_in_role_reads(world: PmWorld, sam: str) -> None:
|
||||
for what in ENDPOINTS:
|
||||
r = world.shop(sam).get(f"/api/pm/{what}")
|
||||
assert r.status_code == 200, (sam, what, r.text)
|
||||
|
||||
|
||||
def test_unsigned_is_refused_and_nothing_writes(world: PmWorld) -> None:
|
||||
c = anon(world.shop)
|
||||
for what in ENDPOINTS:
|
||||
r = c.get(f"/api/pm/{what}")
|
||||
assert r.status_code == 401, (what, r.text)
|
||||
for what in ENDPOINTS:
|
||||
r = world.shop("pm.paula").post(f"/api/pm/{what}", json={})
|
||||
assert r.status_code in (404, 405), (what, r.status_code)
|
||||
# the PM user cannot move a packet
|
||||
pkt = get_ok(world.shop("pm.paula"), f"/api/packets/{world.k['k_ready']}")
|
||||
r = world.shop("pm.paula").post(
|
||||
f"/api/packets/{pkt['id']}/transitions",
|
||||
json={"action": "assign", "expect": {"status": pkt["status"], "dept": pkt["dept"]["code"]}},
|
||||
)
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
|
||||
def test_bad_period_is_refused(world: PmWorld) -> None:
|
||||
r = world.shop("pm.paula").get("/api/pm/overview", params={"period": "7"})
|
||||
assert r.status_code in (400, 422), r.text
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Phase 8: the Project Management exports. The CSV per table and for every table (the header rows, the formula
|
||||
guard, the BOM and CRLF), the production status report PDF (pages, cover, the controlled copy stamp on every page),
|
||||
and one print_log row plus one audit row `report exported` for each export.
|
||||
|
||||
The PM world (pmhelpers.py) is built once for the module; one draft's title starts with `=`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from closehelpers import flat, page_texts, pdf_pages, q1, qall
|
||||
from pmhelpers import (
|
||||
FORMULA_TITLE,
|
||||
HEADS,
|
||||
PERIODS,
|
||||
PmWorld,
|
||||
anon,
|
||||
module_shop,
|
||||
pm_world,
|
||||
titles,
|
||||
)
|
||||
from pshelpers import needs_pg
|
||||
from shophelpers import run
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
TABLE_KEYS = tuple(HEADS) # projects, aging_*, hours_*, quality_*, jobs
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def world(tmp_path_factory: pytest.TempPathFactory) -> Iterator[PmWorld]:
|
||||
with module_shop(tmp_path_factory.mktemp("pm_export")) as shop:
|
||||
w = pm_world(shop)
|
||||
run(shop.settings, "UPDATE packets SET title = %s WHERE id = %s", (FORMULA_TITLE, w.k["k_draft"]))
|
||||
yield w
|
||||
|
||||
|
||||
def export(w: PmWorld, table: str, sam: str = "pm.paula", **params: Any) -> Any:
|
||||
return w.shop(sam).get("/api/pm/export.csv", params={"table": table, **params})
|
||||
|
||||
|
||||
def parse(body: bytes) -> list[list[str]]:
|
||||
assert body.startswith(b"\xef\xbb\xbf"), body[:10] # the BOM, for Excel
|
||||
assert b"\r\n" in body and body.count(b"\n") == body.count(b"\r\n"), "every line ends CRLF"
|
||||
return list(csv.reader(io.StringIO(body.decode("utf-8-sig"), newline="")))
|
||||
|
||||
|
||||
def filename(r: Any) -> str:
|
||||
cd = r.headers["content-disposition"]
|
||||
m = re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)"?', cd)
|
||||
assert m, cd
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def print_rows(w: PmWorld, kind: str) -> list[Any]:
|
||||
return qall(
|
||||
w.settings,
|
||||
"SELECT what, pages, by_name, packet_id, unit_id FROM print_log WHERE kind = %s ORDER BY id",
|
||||
(kind,),
|
||||
)
|
||||
|
||||
|
||||
def audit_rows(w: PmWorld) -> list[dict[str, Any]]:
|
||||
return [
|
||||
r[0] for r in qall(w.settings, "SELECT detail FROM audit_events WHERE action = 'report exported' ORDER BY id")
|
||||
]
|
||||
|
||||
|
||||
def header_ok(rows: list[list[str]], name: str, label: str = "All projects", period: str = "90") -> None:
|
||||
assert rows[0] == [f"PESCO Packet Station — {name}"], rows[0]
|
||||
assert rows[1] == ["Scope", label]
|
||||
assert rows[2] == ["History period", PERIODS[period]]
|
||||
assert rows[3][0] == "Exported" and rows[3][2] == "by" and rows[3][3] == "Paula PM" and rows[3][1]
|
||||
assert rows[4] == [] or rows[4] == [""]
|
||||
|
||||
|
||||
def table_at(rows: list[list[str]], title: str) -> tuple[list[str], list[list[str]]]:
|
||||
"""The head and the body rows of the table titled `title` in a parsed CSV."""
|
||||
i = rows.index([title])
|
||||
head = rows[i + 1]
|
||||
body = []
|
||||
for r in rows[i + 2 :]:
|
||||
if r in ([], [""]):
|
||||
break
|
||||
body.append(r)
|
||||
return head, body
|
||||
|
||||
|
||||
# ================================================================ CSV
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", TABLE_KEYS)
|
||||
def test_csv_per_table(world: PmWorld, key: str) -> None:
|
||||
r = export(world, key)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.headers["content-type"].startswith("text/csv")
|
||||
assert "attachment" in r.headers["content-disposition"]
|
||||
name = filename(r)
|
||||
assert name.startswith("PacketStation_") and name.endswith(f"_{world.today.isoformat()}.csv"), name
|
||||
assert re.fullmatch(r"PacketStation_[A-Za-z0-9_]+_\d{4}-\d{2}-\d{2}\.csv", name), name
|
||||
rows = parse(r.content)
|
||||
title = titles()[key]
|
||||
assert rows[0][0].startswith("PESCO Packet Station — ")
|
||||
head, body = table_at(rows, title)
|
||||
assert head == HEADS[key]
|
||||
assert all(len(b) == len(head) for b in body), key
|
||||
|
||||
|
||||
def test_csv_jobs_and_the_formula_guard(world: PmWorld) -> None:
|
||||
r = export(world, "jobs")
|
||||
assert r.status_code == 200, r.text
|
||||
assert filename(r) == f"PacketStation_Jobs_{world.today.isoformat()}.csv"
|
||||
rows = parse(r.content)
|
||||
header_ok(rows, "Jobs")
|
||||
head, body = table_at(rows, titles()["jobs"])
|
||||
assert head == HEADS["jobs"]
|
||||
assert len(body) == 14 # 12 open and 2 filed
|
||||
col = head.index("Title")
|
||||
assert "'" + FORMULA_TITLE in [b[col] for b in body]
|
||||
assert FORMULA_TITLE not in [b[col] for b in body]
|
||||
every = [c for b in rows for c in b]
|
||||
assert not any(c[:1] in ("=", "+", "-", "@", "\t") for c in every if c), "the formula guard on every cell"
|
||||
|
||||
|
||||
def test_csv_all(world: PmWorld) -> None:
|
||||
r = export(world, "all")
|
||||
assert r.status_code == 200, r.text
|
||||
assert filename(r) == f"PacketStation_All_reports_{world.today.isoformat()}.csv"
|
||||
rows = parse(r.content)
|
||||
header_ok(rows, "All reports")
|
||||
for key in TABLE_KEYS:
|
||||
head, _ = table_at(rows, titles()[key])
|
||||
assert head == HEADS[key], key
|
||||
_, units = table_at(rows, titles()["projects"])
|
||||
assert len(units) >= 2 + 8 # every project row and every unit (expand all)
|
||||
|
||||
|
||||
def test_csv_follows_scope_and_period(world: PmWorld) -> None:
|
||||
pa = world.projects["L670"]
|
||||
r = export(world, "quality_sentback", project_id=pa, period="365")
|
||||
assert r.status_code == 200, r.text
|
||||
rows = parse(r.content)
|
||||
assert rows[1] == ["Scope", "Project L670"]
|
||||
assert rows[2] == ["History period", PERIODS["365"]]
|
||||
_, body = table_at(rows, titles("365")["quality_sentback"])
|
||||
assert len(body) == 4
|
||||
|
||||
|
||||
def test_csv_unknown_table(world: PmWorld) -> None:
|
||||
r = export(world, "salaries")
|
||||
assert r.status_code == 404 and r.json()["code"] == "not_found", r.text
|
||||
|
||||
|
||||
def test_csv_is_logged(world: PmWorld) -> None:
|
||||
before_print = len(print_rows(world, "report-csv"))
|
||||
before_audit = len(audit_rows(world))
|
||||
assert export(world, "jobs").status_code == 200
|
||||
rows = print_rows(world, "report-csv")
|
||||
assert len(rows) == before_print + 1
|
||||
what, pages, by_name, packet_id, unit_id = rows[-1]
|
||||
assert what.endswith(" · CSV · All projects") and by_name == "Paula PM"
|
||||
assert packet_id is None and unit_id is None and pages is None
|
||||
audit = audit_rows(world)
|
||||
assert len(audit) == before_audit + 1
|
||||
detail = audit[-1]
|
||||
assert set(detail) >= {"name", "format", "scope", "period"}
|
||||
assert str(detail["format"]).lower() == "csv" and detail["scope"] == "All projects"
|
||||
assert detail["period"] in ("90", PERIODS["90"])
|
||||
|
||||
|
||||
def test_export_needs_a_session(world: PmWorld) -> None:
|
||||
c = anon(world.shop)
|
||||
assert c.get("/api/pm/export.csv", params={"table": "all"}).status_code == 401
|
||||
assert c.get("/api/pm/report.pdf").status_code == 401
|
||||
assert world.shop("clerk.clara").get("/api/pm/export.csv", params={"table": "jobs"}).status_code == 200
|
||||
|
||||
|
||||
# ================================================================ PDF
|
||||
|
||||
|
||||
def test_report_pdf(world: PmWorld) -> None:
|
||||
before = len(print_rows(world, "report-pdf"))
|
||||
before_audit = len(audit_rows(world))
|
||||
r = world.shop("pm.paula").get("/api/pm/report.pdf")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.headers["content-type"].startswith("application/pdf")
|
||||
assert r.headers["content-disposition"].startswith("inline")
|
||||
assert filename(r) == f"PESCO_Production_Status_{world.today.isoformat()}.pdf"
|
||||
data = r.content
|
||||
assert data[:5] == b"%PDF-"
|
||||
pages = pdf_pages(data)
|
||||
assert pages > 5
|
||||
texts = [flat(t) for t in page_texts(data)]
|
||||
assert "PRODUCTION STATUS REPORT" in texts[0]
|
||||
assert "Packet Station · All projects" in texts[0]
|
||||
for i, t in enumerate(texts):
|
||||
assert "Controlled copy" in t, f"page {i + 1} has no stamp"
|
||||
body = texts[1:]
|
||||
for i, t in enumerate(body):
|
||||
assert "Production status report" in t, i
|
||||
assert "CONFIDENTIAL & PROPRIETARY" in t, i
|
||||
every = " ".join(texts)
|
||||
for part in ("SUMMARY", "PROJECTS", "FLOW", "LABOR", "ISSUES"):
|
||||
assert part in every, part
|
||||
assert "L670" in every
|
||||
# logged once with the page count
|
||||
rows = print_rows(world, "report-pdf")
|
||||
assert len(rows) == before + 1
|
||||
what, logged_pages, by_name, packet_id, _ = rows[-1]
|
||||
assert what == "Production status report · PDF · All projects"
|
||||
assert logged_pages == pages and by_name == "Paula PM" and packet_id is None
|
||||
audit = audit_rows(world)
|
||||
assert len(audit) == before_audit + 1 and str(audit[-1]["format"]).lower() == "pdf"
|
||||
|
||||
|
||||
def test_report_pdf_download_and_scope(world: PmWorld) -> None:
|
||||
r = world.shop("pm.paula").get(
|
||||
"/api/pm/report.pdf", params={"download": 1, "project_id": world.projects["L670"], "period": "all"}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.headers["content-disposition"].startswith("attachment")
|
||||
first = flat(page_texts(r.content)[0])
|
||||
assert "Packet Station · Project L670" in first
|
||||
row = q1(world.settings, "SELECT what FROM print_log WHERE kind = 'report-pdf' ORDER BY id DESC LIMIT 1")
|
||||
assert row[0] == "Production status report · PDF · Project L670"
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Phase 8: presence. Who is on which screen and packet, with the role shown; locked sessions drop out and come
|
||||
back on unlock; revoked sessions are gone at once. Driven through the EventHub directly (as test_events.py does)
|
||||
and through the API (`/api/events`, `/api/auth/lock`, `/api/auth/unlock`, the idle lock in `resolve`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fakedir import FAKE_PASSWORD, FakeDirectory
|
||||
from fastapi.testclient import TestClient
|
||||
from pshelpers import ORIGIN_HEADERS, needs_pg, sign_in
|
||||
|
||||
from packetstation import db, events
|
||||
from packetstation.settings import Settings
|
||||
|
||||
USER_KEYS = {"user_id", "display_name", "role", "screen", "packet_id", "since"}
|
||||
|
||||
|
||||
def _sub(
|
||||
hub: events.EventHub,
|
||||
name: str,
|
||||
*,
|
||||
screen: str | None = None,
|
||||
packet_id: int | None = None,
|
||||
role: str = "Supervisor",
|
||||
user_id: Any = None,
|
||||
session_id: Any = None,
|
||||
) -> events.Subscriber:
|
||||
return hub.subscribe(
|
||||
session_id=session_id or uuid.uuid4(),
|
||||
user_id=user_id or uuid.uuid4(),
|
||||
display_name=name,
|
||||
screen=screen,
|
||||
packet_id=packet_id,
|
||||
role=role,
|
||||
)
|
||||
|
||||
|
||||
async def _drain(q: asyncio.Queue[events.Item]) -> list[events.Item]:
|
||||
await asyncio.sleep(0) # let call_soon_threadsafe callbacks run
|
||||
out = []
|
||||
while not q.empty():
|
||||
out.append(q.get_nowait())
|
||||
return out
|
||||
|
||||
|
||||
def _on(hub: events.EventHub, packet_id: int) -> list[tuple[str, str, str | None]]:
|
||||
return [(u["display_name"], u["role"], u["screen"]) for u in hub.presence()["users"] if u["packet_id"] == packet_id]
|
||||
|
||||
|
||||
def _names(hub: events.EventHub) -> list[str]:
|
||||
return [u["display_name"] for u in hub.presence()["users"]]
|
||||
|
||||
|
||||
def _session(hub: events.EventHub, sub: events.Subscriber, action: str) -> None:
|
||||
hub._deliver(json.dumps({"event": "session", "session_id": sub.session_id, "action": action}))
|
||||
|
||||
|
||||
# ================================================================ the hub
|
||||
|
||||
|
||||
def test_two_users_on_one_packet_see_each_other() -> None:
|
||||
async def run() -> None:
|
||||
hub = events.EventHub()
|
||||
wes = _sub(hub, "Wes Super", screen="supervisor", packet_id=7, role="Supervisor")
|
||||
andy = _sub(hub, "Andy Welder", screen="stall", packet_id=7, role="Weld Stall")
|
||||
_sub(hub, "Clara Clerk", screen="clerical", role="Clerical")
|
||||
users = hub.presence()["users"]
|
||||
assert all(set(u) == USER_KEYS for u in users), users
|
||||
assert all(u["since"] for u in users)
|
||||
assert sorted(_on(hub, 7)) == [
|
||||
("Andy Welder", "Weld Stall", "stall"),
|
||||
("Wes Super", "Supervisor", "supervisor"),
|
||||
]
|
||||
last = (await _drain(wes.queue))[-1]
|
||||
assert last[0] == "presence" and {u["display_name"] for u in last[1]["users"]} == {
|
||||
"Wes Super",
|
||||
"Andy Welder",
|
||||
"Clara Clerk",
|
||||
}
|
||||
assert (await _drain(andy.queue))[-1] == last
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_one_entry_per_user_screen_and_packet() -> None:
|
||||
async def run() -> None:
|
||||
hub = events.EventHub()
|
||||
uid = uuid.uuid4()
|
||||
_sub(hub, "Wes Super", screen="supervisor", packet_id=7, user_id=uid)
|
||||
_sub(hub, "Wes Super", screen="supervisor", packet_id=7, user_id=uid) # a second tab
|
||||
_sub(hub, "Wes Super", screen="supervisor", packet_id=8, user_id=uid)
|
||||
assert len(_on(hub, 7)) == 1 and len(_on(hub, 8)) == 1
|
||||
assert _names(hub) == ["Wes Super", "Wes Super"]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_leaving_drops_out() -> None:
|
||||
async def run() -> None:
|
||||
hub = events.EventHub()
|
||||
wes = _sub(hub, "Wes Super", packet_id=7)
|
||||
andy = _sub(hub, "Andy Welder", packet_id=7, role="Weld Stall")
|
||||
await _drain(wes.queue)
|
||||
hub.unsubscribe(andy)
|
||||
got = await _drain(wes.queue)
|
||||
assert got and got[-1][0] == "presence"
|
||||
assert [u["display_name"] for u in got[-1][1]["users"]] == ["Wes Super"]
|
||||
assert _on(hub, 7) == [("Wes Super", "Supervisor", None)]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_locked_hides_and_unlocked_brings_back() -> None:
|
||||
async def run() -> None:
|
||||
hub = events.EventHub()
|
||||
wes = _sub(hub, "Wes Super", packet_id=7)
|
||||
andy = _sub(hub, "Andy Welder", packet_id=7, role="Weld Stall")
|
||||
await _drain(wes.queue)
|
||||
await _drain(andy.queue)
|
||||
|
||||
_session(hub, andy, "locked")
|
||||
assert _names(hub) == ["Wes Super"]
|
||||
to_wes = await _drain(wes.queue)
|
||||
assert [n for n, _ in to_wes] == ["presence"] # the others get presence, never the session event
|
||||
assert [u["display_name"] for u in to_wes[-1][1]["users"]] == ["Wes Super"]
|
||||
to_andy = await _drain(andy.queue)
|
||||
assert ("session", {"session_id": andy.session_id, "action": "locked"}) in to_andy
|
||||
|
||||
_session(hub, andy, "unlocked")
|
||||
assert sorted(_names(hub)) == ["Andy Welder", "Wes Super"]
|
||||
to_wes = await _drain(wes.queue)
|
||||
assert to_wes and to_wes[-1][0] == "presence" and len(to_wes[-1][1]["users"]) == 2
|
||||
assert ("session", {"session_id": andy.session_id, "action": "unlocked"}) in await _drain(andy.queue)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_a_locked_session_hides_only_itself() -> None:
|
||||
async def run() -> None:
|
||||
hub = events.EventHub()
|
||||
uid = uuid.uuid4()
|
||||
tablet = _sub(hub, "Wes Super", packet_id=7, user_id=uid)
|
||||
_sub(hub, "Wes Super", packet_id=7, user_id=uid) # the same person on a second device
|
||||
_session(hub, tablet, "locked")
|
||||
assert _on(hub, 7) == [("Wes Super", "Supervisor", None)]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_revoked_is_gone_at_once() -> None:
|
||||
async def run() -> None:
|
||||
hub = events.EventHub()
|
||||
wes = _sub(hub, "Wes Super", packet_id=7)
|
||||
andy = _sub(hub, "Andy Welder", packet_id=7, role="Weld Stall")
|
||||
await _drain(wes.queue)
|
||||
_session(hub, andy, "revoked")
|
||||
assert _names(hub) == ["Wes Super"] # before the stream has ended
|
||||
got = await _drain(wes.queue)
|
||||
assert got and got[-1][0] == "presence" and len(got[-1][1]["users"]) == 1
|
||||
assert ("session", {"session_id": andy.session_id, "action": "revoked"}) in await _drain(andy.queue)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_stream_forwards_lock_and_keeps_going() -> None:
|
||||
async def run() -> list[str]:
|
||||
hub = events.EventHub()
|
||||
sub = _sub(hub, "Wes Super", screen="supervisor")
|
||||
await _drain(sub.queue)
|
||||
|
||||
async def connected() -> bool:
|
||||
return False
|
||||
|
||||
gen = events.stream(hub, sub, hello={"session_id": sub.session_id}, is_disconnected=connected, ping_seconds=5)
|
||||
chunks = [await gen.__anext__()]
|
||||
_session(hub, sub, "locked")
|
||||
for _ in range(3):
|
||||
chunks.append(await asyncio.wait_for(gen.__anext__(), 2))
|
||||
if chunks[-1].startswith("event: session"):
|
||||
break
|
||||
_session(hub, sub, "revoked")
|
||||
rest = [c async for c in gen]
|
||||
assert hub.presence() == {"users": []}
|
||||
return chunks + rest
|
||||
|
||||
chunks = asyncio.run(run())
|
||||
assert chunks[0].startswith("event: hello\n")
|
||||
names = [c.split("\n", 1)[0] for c in chunks[1:]]
|
||||
assert "event: session" in names # the lock reached the locked session's own stream
|
||||
assert any('"action":"locked"' in c for c in chunks)
|
||||
|
||||
|
||||
def test_presence_by_screen() -> None:
|
||||
async def run() -> None:
|
||||
hub = events.EventHub()
|
||||
_sub(hub, "Wes Super", screen="supervisor")
|
||||
_sub(hub, "Lou Lead", screen="supervisor")
|
||||
_sub(hub, "Quinn QC", screen="qc", role="Q.C.")
|
||||
locked = _sub(hub, "Andy Welder", screen="stall", role="Weld Stall")
|
||||
_session(hub, locked, "locked")
|
||||
by = hub.presence_by_screen()
|
||||
assert sorted(u["display_name"] for u in by["supervisor"]) == ["Lou Lead", "Wes Super"]
|
||||
assert [u["display_name"] for u in by["qc"]] == ["Quinn QC"]
|
||||
assert not by.get("stall")
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# ================================================================ through the API
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[TestClient]:
|
||||
from packetstation.main import create_app
|
||||
|
||||
with TestClient(create_app(pg_settings, directory=fake_dir), headers=ORIGIN_HEADERS) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _signed(client: TestClient, sam: str) -> tuple[TestClient, str]:
|
||||
c = TestClient(client.app, headers=ORIGIN_HEADERS)
|
||||
r = sign_in(c, sam)
|
||||
assert r.status_code == 200, r.text
|
||||
return c, str(r.json()["session"]["id"])
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_events_stream_carries_the_role(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The stream body is replaced by one that answers the hub's presence and ends, so the request returns."""
|
||||
from packetstation.api import events as events_api
|
||||
|
||||
async def snapshot(
|
||||
hub: events.EventHub, sub: events.Subscriber, *, hello: dict[str, Any], **_: Any
|
||||
) -> AsyncIterator[str]:
|
||||
try:
|
||||
yield events.format_sse("presence", hub.presence())
|
||||
finally:
|
||||
hub.unsubscribe(sub)
|
||||
|
||||
monkeypatch.setattr(events_api, "stream", snapshot)
|
||||
|
||||
def users(c: TestClient, **params: Any) -> list[dict[str, Any]]:
|
||||
r = c.get("/api/events", params=params)
|
||||
assert r.status_code == 200, r.text
|
||||
line = next(x for x in r.text.splitlines() if x.startswith("data: "))
|
||||
out: list[dict[str, Any]] = json.loads(line[6:])["users"]
|
||||
return out
|
||||
|
||||
wes, _ = _signed(client, "sup.wes")
|
||||
me = users(wes, screen="supervisor", packet_id=5)
|
||||
assert [(u["display_name"], u["role"], u["screen"], u["packet_id"]) for u in me] == [
|
||||
("Wes Super", "Supervisor", "supervisor", 5)
|
||||
]
|
||||
assert me[0]["since"]
|
||||
# a screen the user does not have: no screen, the first role's label
|
||||
other = users(wes, screen="qc")
|
||||
assert [(u["role"], u["screen"]) for u in other] == [("Supervisor", None)]
|
||||
for sam, screen, role in (
|
||||
("pm.paula", "pm", "Project Mgmt"),
|
||||
("weld.andy", "stall", "Weld Stall"),
|
||||
("clerk.clara", "clerical", "Clerical"),
|
||||
("wh.wade", "warehouse", "Warehouse"),
|
||||
("qc.quinn", "qc", "Q.C."),
|
||||
("admin.ann", "admin", "Admin"),
|
||||
):
|
||||
c, _ = _signed(client, sam)
|
||||
assert [u["role"] for u in users(c, screen=screen)] == [role], sam
|
||||
paula, _ = _signed(client, "pm.paula")
|
||||
assert [u["role"] for u in users(paula)] == ["Project Mgmt"] # no screen: the primary role
|
||||
|
||||
|
||||
async def _wait_session(q: asyncio.Queue[events.Item], action: str) -> dict[str, Any]:
|
||||
while True:
|
||||
name, data = await asyncio.wait_for(q.get(), timeout=10)
|
||||
if name == "session" and data.get("action") == action:
|
||||
return data
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_lock_and_unlock_publish_session_events(client: TestClient, pg_settings: Settings) -> None:
|
||||
wes, sid = _signed(client, "sup.wes")
|
||||
|
||||
async def run() -> None:
|
||||
hub = events.EventHub(connect=lambda: db.connect_as("app", pg_settings, autocommit=True))
|
||||
sub = _sub(hub, "Wes Super", screen="supervisor", packet_id=3, session_id=sid)
|
||||
other = _sub(hub, "Andy Welder", screen="stall", packet_id=3, role="Weld Stall")
|
||||
hub.start()
|
||||
try:
|
||||
for _ in range(100):
|
||||
if hub.connected:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
assert hub.connected
|
||||
r = await asyncio.to_thread(wes.post, "/api/auth/lock")
|
||||
assert r.status_code == 204, r.text
|
||||
data = await _wait_session(sub.queue, "locked")
|
||||
assert data["session_id"] == sid
|
||||
assert _names(hub) == ["Andy Welder"]
|
||||
assert other.queue.qsize() >= 1 # the others got a presence update
|
||||
r = await asyncio.to_thread(
|
||||
wes.post, "/api/auth/unlock", json={"factor": "password", "secret": FAKE_PASSWORD}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
await _wait_session(sub.queue, "unlocked")
|
||||
assert sorted(_names(hub)) == ["Andy Welder", "Wes Super"]
|
||||
finally:
|
||||
await asyncio.to_thread(hub.stop)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_idle_lock_publishes(client: TestClient, pg_settings: Settings) -> None:
|
||||
wes, sid = _signed(client, "sup.wes")
|
||||
|
||||
async def run() -> None:
|
||||
hub = events.EventHub(connect=lambda: db.connect_as("app", pg_settings, autocommit=True))
|
||||
sub = _sub(hub, "Wes Super", session_id=sid)
|
||||
hub.start()
|
||||
try:
|
||||
for _ in range(100):
|
||||
if hub.connected:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
assert hub.connected
|
||||
with db.connect_as("app", pg_settings, autocommit=True) as conn:
|
||||
conn.execute("UPDATE sessions SET last_seen_at = now() - interval '5 minutes' WHERE id = %s", (sid,))
|
||||
r = await asyncio.to_thread(wes.get, "/api/auth/me")
|
||||
assert r.status_code == 200 and r.json()["session"]["locked"] is True, r.text
|
||||
await _wait_session(sub.queue, "locked")
|
||||
assert _names(hub) == []
|
||||
finally:
|
||||
await asyncio.to_thread(hub.stop)
|
||||
|
||||
asyncio.run(run())
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Phase 8: the find bar search (`q=`), one test per field of the vocabulary, on `GET /api/packets`.
|
||||
|
||||
Every word must match some field; a word matches by substring, case insensitive. Each distinctive value below is
|
||||
on one packet only, so a search returns exactly the packets that carry it. The world is built once per module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pmhelpers import add_alias, make_project, make_unit, module_shop, quick_draft
|
||||
from pshelpers import needs_pg
|
||||
from shophelpers import Shop, ok, run, step
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
LONG = "Q" * 60 # a title of exactly 60 characters
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchWorld:
|
||||
shop: Shop
|
||||
k: dict[str, int] = field(default_factory=dict)
|
||||
every: set[int] = field(default_factory=set)
|
||||
|
||||
def ids(self, *keys: str) -> set[int]:
|
||||
return {self.k[x] for x in keys}
|
||||
|
||||
|
||||
def make(
|
||||
shop: Shop,
|
||||
w: SearchWorld,
|
||||
key: str,
|
||||
unit_id: int,
|
||||
title: str,
|
||||
*,
|
||||
ready_dept: str | None = None,
|
||||
**over: Any,
|
||||
) -> dict[str, Any]:
|
||||
pkt = quick_draft(shop, unit_id, title, n=len(w.k) + 1, send=ready_dept, **over)
|
||||
w.k[key] = int(pkt["id"])
|
||||
w.every.add(int(pkt["id"]))
|
||||
return pkt
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def world(tmp_path_factory: pytest.TempPathFactory) -> Iterator[SearchWorld]:
|
||||
with module_shop(tmp_path_factory.mktemp("search")) as shop:
|
||||
s = shop.settings
|
||||
w = SearchWorld(shop)
|
||||
p1 = make_project(s, "L489", "BPX separator", customer="BPX Energy", po="PO-4471")
|
||||
add_alias(s, p1, "OLD489X")
|
||||
u52, u53, u54 = (make_unit(s, p1, lot) for lot in ("052", "053", "054"))
|
||||
p2 = make_project(s, "L512", "Pump skid", customer="Vortex Oil", po="PO-9")
|
||||
u2 = make_unit(s, p2, "001")
|
||||
|
||||
make(shop, w, "title", u52, "Inlet spool", drawing="D-48810", rev="B")
|
||||
make(shop, w, "op", u53, "Bravo spool", op="HYDRO-TEST")
|
||||
make(shop, w, "wps", u53, "Charlie spool", wps="WPS-77X")
|
||||
make(shop, w, "cust", u54, "Delta spool", customer="Acme Gas")
|
||||
make(
|
||||
shop,
|
||||
w,
|
||||
"wo",
|
||||
u54,
|
||||
"Echo spool",
|
||||
work_orders=[{"wo_base": "5ZZQ", "lot": "007", "split": "0", "sub": "0", "qty": 1, "target_hrs": 1}],
|
||||
)
|
||||
make(
|
||||
shop,
|
||||
w,
|
||||
"heat",
|
||||
u52,
|
||||
"Foxtrot spool",
|
||||
bom=[
|
||||
{"item": 1, "qty": 1, "part_id": "FLG-9876", "description": "Weld neck flange"},
|
||||
{"item": 2, "qty": 1, "part_id": "FLG-9877", "description": "Blind flange"},
|
||||
],
|
||||
)
|
||||
make(
|
||||
shop,
|
||||
w,
|
||||
"mat",
|
||||
u53,
|
||||
"Golf spool",
|
||||
bom=[
|
||||
{"item": 1, "qty": 2, "part_id": "GSK-1", "description": "Spiral wound gasket", "material": "A182-F316"}
|
||||
],
|
||||
)
|
||||
make(shop, w, "p2", u2, "Hotel spool")
|
||||
make(shop, w, "asg", u2, "India spool", ready_dept="03")
|
||||
make(shop, w, "co", u52, "Juliet spool")
|
||||
make(shop, w, "dept", u2, "Kilo spool", ready_dept="06")
|
||||
make(shop, w, "flag", u54, "Lima spool")
|
||||
make(shop, w, "long", u54, LONG)
|
||||
|
||||
# the assigned welder and the stamp
|
||||
asg = ok(shop("sup.wes").get(f"/api/packets/{w.k['asg']}"))
|
||||
ok(
|
||||
step(
|
||||
shop("sup.wes"), asg, "assign", station_id=shop.stall("03", "Stall 2"), welder_id=shop.uid("weld.andy")
|
||||
)
|
||||
)
|
||||
run(s, "UPDATE packets SET welder_stamp = 'A7Q' WHERE id = %s", (w.k["asg"],))
|
||||
run(s, "UPDATE users SET welder_stamp = 'A7Q' WHERE sam_account = 'weld.andy'")
|
||||
# two BOM lines with one heat and two welds with one stamp: one packet, never two rows
|
||||
run(s, "UPDATE bom_lines SET heat = 'HX44921' WHERE packet_id = %s", (w.k["heat"],))
|
||||
for no in (1, 2):
|
||||
run(
|
||||
s,
|
||||
"INSERT INTO welds (packet_id, weld_no, items, type, stamp, welder_id) VALUES (%s, %s, '{1}',"
|
||||
" 'Fillet', 'ZQ9', (SELECT id FROM users WHERE sam_account = 'lead.lou'))",
|
||||
(w.k["heat"], no),
|
||||
)
|
||||
# a change order with an ECN
|
||||
run(
|
||||
s,
|
||||
"INSERT INTO change_orders (packet_id, co_no, origin, review, minor, ecn, reason)"
|
||||
" VALUES (%s, 47, 'manual', 'approved', true, 'ECN-7781', 'Nozzle moved')",
|
||||
(w.k["co"],),
|
||||
)
|
||||
# a VISUAL draft with a flag kind
|
||||
run(
|
||||
s,
|
||||
"UPDATE packets SET source_kind = 'visual', flags = '{rev_mismatch}', drawing_state = 'rev_mismatch'"
|
||||
" WHERE id = %s",
|
||||
(w.k["flag"],),
|
||||
)
|
||||
yield w
|
||||
|
||||
|
||||
def found(w: SearchWorld, q: str, sam: str = "sup.wes") -> list[int]:
|
||||
r = w.shop(sam).get("/api/packets", params={"q": q})
|
||||
assert r.status_code == 200, r.text
|
||||
return [int(c["id"]) for c in r.json()["packets"]]
|
||||
|
||||
|
||||
def hits(w: SearchWorld, q: str) -> set[int]:
|
||||
got = found(w, q)
|
||||
assert len(got) == len(set(got)), ("duplicate rows", q, got)
|
||||
return set(got)
|
||||
|
||||
|
||||
P1 = ("title", "op", "wps", "cust", "wo", "heat", "mat", "co", "flag", "long")
|
||||
P2 = ("p2", "asg", "dept")
|
||||
DRAFTS = tuple(k for k in (*P1, *P2) if k not in ("asg", "dept"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("q", "keys"),
|
||||
[
|
||||
("L512", P2), # project number
|
||||
("pump", P2), # project name
|
||||
("L512001", P2), # unit number
|
||||
("L5121", P2), # unit number with the lot's zeros stripped (L512 || 1)
|
||||
("L48952", ("title", "heat", "co")), # the same for lot 052
|
||||
("lot 52", ("title", "heat", "co")), # lot 052 by its number
|
||||
("HYDRO-TEST", ("op",)), # operation
|
||||
("D-48810", ("title",)), # drawing
|
||||
("D-48810 rev B", ("title",)), # drawing and rev
|
||||
("WPS-77X", ("wps",)), # WPS
|
||||
("acme", ("cust",)), # packet customer
|
||||
("vortex", P2), # project customer
|
||||
("4471", P1), # project PO
|
||||
("OLD489X", P1), # project alias
|
||||
("5zzq", ("wo",)), # work order base
|
||||
("5ZZQ/007", ("wo",)), # work order and lot
|
||||
("hx449", ("heat",)), # BOM heat
|
||||
("FLG-9876", ("heat",)), # BOM part id
|
||||
("spiral", ("mat",)), # BOM description
|
||||
("A182-F316", ("mat",)), # BOM material
|
||||
("andy", ("asg",)), # assigned welder's name
|
||||
("A7Q", ("asg",)), # assigned welder's stamp
|
||||
("ZQ9", ("heat",)), # weld stamp
|
||||
("lou", ("heat",)), # the welder named on a weld
|
||||
("CO-47", ("co",)), # change order number
|
||||
("ECN-7781", ("co",)), # ECN
|
||||
("stall", ("asg",)), # station name
|
||||
("bldg", ("dept",)), # department name
|
||||
("sent", ("asg",)), # status label: assigned is "Sent to station"
|
||||
("rev_mismatch", ("flag",)), # flag kind
|
||||
("flagged", DRAFTS), # every flagged draft (hand drafts and the flagged VISUAL draft)
|
||||
("inlet", ("title",)), # title
|
||||
("INLET", ("title",)), # case insensitive
|
||||
],
|
||||
)
|
||||
def test_search_field(world: SearchWorld, q: str, keys: tuple[str, ...]) -> None:
|
||||
assert hits(world, q) == world.ids(*keys)
|
||||
|
||||
|
||||
def test_lot_and_rev_alone_match_everything(world: SearchWorld) -> None:
|
||||
assert hits(world, "lot") == world.every
|
||||
assert hits(world, "rev") == world.every
|
||||
assert hits(world, "lot 052") == world.ids("title", "heat", "co")
|
||||
|
||||
|
||||
def test_several_words_narrow(world: SearchWorld) -> None:
|
||||
assert hits(world, "L489") == world.ids(*P1)
|
||||
assert hits(world, "L489 spool") == world.ids(*(k for k in P1 if k != "long"))
|
||||
assert hits(world, "L489 52") == world.ids("title", "heat", "co")
|
||||
assert hits(world, "L489 52 inlet") == world.ids("title")
|
||||
assert hits(world, "L512 HYDRO-TEST") == set()
|
||||
assert hits(world, " inlet L489 ") == world.ids("title")
|
||||
|
||||
|
||||
def test_no_duplicates_from_child_rows(world: SearchWorld) -> None:
|
||||
for q in ("hx449", "ZQ9", "FLG", "lou", "flange"):
|
||||
got = found(world, q)
|
||||
assert got == [world.k["heat"]], (q, got)
|
||||
|
||||
|
||||
def test_long_word_is_cut_at_60(world: SearchWorld) -> None:
|
||||
assert hits(world, LONG) == world.ids("long")
|
||||
assert hits(world, LONG + "ZZZZ") == world.ids("long") # cut to the first 60 characters
|
||||
assert hits(world, "Q" * 61) == world.ids("long")
|
||||
|
||||
|
||||
def test_only_eight_words_count(world: SearchWorld) -> None:
|
||||
assert hits(world, " ".join(["inlet"] * 8)) == world.ids("title")
|
||||
assert hits(world, " ".join(["lot"] * 8 + ["inlet"])) == world.every
|
||||
|
||||
|
||||
def test_like_wildcards_are_literal(world: SearchWorld) -> None:
|
||||
assert hits(world, "%") == set()
|
||||
assert hits(world, "_") == world.ids("flag") # only the flag kind has an underscore
|
||||
Reference in New Issue
Block a user