"""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 on the VISUAL tickets." 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] == "5.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 # over by the ticket hours (C1), not the timer assert cells[4] == "5.2" and cells[5] == "4.5" and cells[6] == "+0.7 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", "admin.ann"]) def test_pm_and_admin_read(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) @pytest.mark.parametrize("sam", ["clerk.clara", "weld.andy", "qc.quinn", "wh.wade", "sup.wes"]) def test_other_roles_are_refused(world: PmWorld, sam: str) -> None: # change C10: PS-ProjectMgmt and Admin only for what in [*ENDPOINTS, "export.csv", "report.pdf"]: r = world.shop(sam).get(f"/api/pm/{what}") assert r.status_code == 403, (sam, what, r.text) me = get_ok(world.shop(sam), "/api/auth/me") assert "pm" not in me.get("screens", []), me 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