Files
PacketStation/tests/server/test_c8_checklists.py
T
spencermandClaude Opus 5.5 70a97398f1
ci / server (pull_request) Successful in 12m48s
ci / frontend (pull_request) Successful in 54s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 4m38s
Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
- C1 (issue 1): over target follows VISUAL ticket hours, not the timer.
- C2 (issue 24): a Q.C. reject always needs a reason; accept needs an NCR number after a reject without one.
- C3 (issue 25): an incomplete unit ITP blocks filing.
- C4 (issue 27): prints open the print dialog on the page, no new tab.
- C5 (issue 31): filing is blocked while VISUAL shows a work order open; the feed sets vclosed on VISUAL packets.
- C6 (issue 8): the data book leaves out the history pages.
- C7 (issue 18): the supervisor approves each received short; accept and filing wait for it.
- C8 (issue 23): per-department checklists, edited by the department supervisor or Admin, frozen at START.
- C9 (issue 32): Q.C. and Clerical may run the stamp check.
- C10 (issue 33): the PM screen and reports are for PS-ProjectMgmt and Admin only.
- C11 (issue 36): the Stall screen lists the packets assigned to the welder, at the supervisor's stall.

Migration 0011. Docs: PLAN (also issue 9 confirmed, issue 4 asked), POLICY, SECURITY, SCHEMA, FEED, README, CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 16:36:49 -06:00

162 lines
7.1 KiB
Python

"""Change C8 (Gitea issue 23): checklists per department.
The department supervisor edits their own department's list, Admin edits any, a lead, a worker and Q.C. do not. A
save carries the version (`base` or If-Match) and is audited. A stage freezes its list at START, so a change reaches
only stages not yet started. A department without its own list uses the default for its kind. Nothing here reads
VISUAL or M-Files."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from shophelpers import (
PLAIN_KEYS,
WELD_KEYS,
Shop,
add_weld,
fresh,
ok,
one,
open_shop,
ready_packet,
refused,
set_filler,
start,
tick_all,
to_station,
)
from packetstation.domain import checklists
from packetstation.settings import Settings
URL = "/api/checklists"
NEW_LIST = [
{"group": "Before welding", "key": "dwg", "text": "Drawing {drawing} Rev {rev} on the bench"},
{"group": "Before welding", "key": "purge", "text": "Back purge set"},
{"group": "After welding", "key": "vt", "text": "Visual done"},
]
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def keys(pkt: dict[str, Any]) -> list[str]:
return [i["key"] for i in pkt["checklist_items"]]
def put(shop: Shop, sam: str, dept: str, items: Any, base: int | None) -> Any:
body: dict[str, Any] = {"items": items}
if base is not None:
body["base"] = base
return shop(sam).put(f"{URL}/{dept}", json=body)
def test_list_and_get(shop: Shop) -> None:
rows = {r["dept_code"]: r for r in ok(shop("sup.wes").get(URL))}
assert "03" in rows and "CL" not in rows and "QC" not in rows
assert rows["03"]["editable"] is True and rows["03"]["weld"] is True and rows["03"]["custom"] is False
assert rows["03"]["version"] == 0 and rows["03"]["dept_name"] == "Weld Parts"
assert not any(r["editable"] for code, r in rows.items() if code != "03")
assert all(r["editable"] for r in ok(shop("admin.ann").get(URL)))
for sam in ("lead.lou", "weld.andy", "qc.quinn", "clerk.clara"):
assert not any(r["editable"] for r in ok(shop(sam).get(URL))), sam
got = ok(shop("qc.quinn").get(f"{URL}/03"))
assert [i["key"] for i in got["items"]] == list(WELD_KEYS) and got["defaults"] == got["items"]
assert got["editable"] is False and got["custom"] is False
assert "{drawing}" in got["items"][0]["text"] # unfilled
plain = ok(shop("sup.wes").get(f"{URL}/09"))
assert [i["key"] for i in plain["items"]] == list(PLAIN_KEYS) and plain["editable"] is False
refused(shop("sup.wes").get(f"{URL}/QC"), 404, "not_found")
def test_who_may_save(shop: Shop) -> None:
for sam in ("lead.lou", "weld.andy", "qc.quinn", "clerk.clara", "pm.paula"):
refused(put(shop, sam, "03", NEW_LIST, 0), 403, "forbidden")
refused(put(shop, "sup.wes", "09", NEW_LIST, 0), 403, "forbidden") # not his department
out = ok(put(shop, "sup.wes", "03", NEW_LIST, 0))
assert out["custom"] is True and out["version"] == 1 and [i["key"] for i in out["items"]] == ["dwg", "purge", "vt"]
# Admin edits any department
out = ok(put(shop, "admin.ann", "09", NEW_LIST, 0))
assert out["custom"] is True and out["dept_code"] == "09"
# the other departments keep the default for their kind
assert ok(shop("sup.wes").get(f"{URL}/04"))["custom"] is False
row = one(
shop.settings,
"SELECT count(*) FROM audit_events WHERE action = 'checklists.dept_updated' AND subject_id = 'checklist:03'",
)
assert row is not None and row[0] == 1
def test_version_cas_and_validation(shop: Shop) -> None:
ok(put(shop, "sup.wes", "03", NEW_LIST, 0))
stale = refused(put(shop, "sup.wes", "03", NEW_LIST, 0), 409, "version_conflict")
assert stale["current"]["version"] == 1 and stale["current"]["custom"] is True
refused(put(shop, "sup.wes", "03", NEW_LIST, None), 428, "if_match_required")
r = shop("sup.wes").put(f"{URL}/03", json={"items": NEW_LIST}, headers={"If-Match": '"1"'})
assert ok(r)["version"] == 2
bad = refused(put(shop, "sup.wes", "03", [{"group": "G", "key": "Bad-Key", "text": "t"}], 2), 422, "bad_checklist")
assert "key" in bad["detail"]
refused(put(shop, "sup.wes", "03", [], 2), 422, "bad_checklist")
refused(put(shop, "sup.wes", "03", [{"group": "G", "key": "x", "text": "{stamp}"}], 2), 422, "bad_checklist")
# null sets the department back to the default for its kind
back = ok(put(shop, "sup.wes", "03", None, 2))
assert back["custom"] is False and back["version"] == 3 and [i["key"] for i in back["items"]] == list(WELD_KEYS)
def test_a_change_reaches_only_stages_not_started(shop: Shop) -> None:
andy = shop("weld.andy")
started = start(shop, to_station(shop, ready_packet(shop, drawing="D-7007", rev="A")))
assigned = to_station(shop, ready_packet(shop), station="Stall 2")
waiting = ready_packet(shop)
ok(put(shop, "sup.wes", "03", NEW_LIST, 0))
# the started stage keeps the default it started with
assert keys(fresh(andy, started["id"])) == list(WELD_KEYS)
refused(andy.put(f"/api/packets/{started['id']}/stage/checklist/purge", json={"done": True}), 422, "invalid_key")
# the stages not yet started read the new list
assert keys(fresh(andy, assigned["id"])) == ["dwg", "purge", "vt"]
assert keys(fresh(shop("sup.wes"), waiting["id"])) == ["dwg", "purge", "vt"]
# START freezes: a later change does not reach it
later = start(shop, assigned)
ok(put(shop, "sup.wes", "03", [*NEW_LIST, {"group": "After welding", "key": "tag", "text": "Tagged"}], 1))
assert keys(fresh(andy, later["id"])) == ["dwg", "purge", "vt"]
assert keys(fresh(shop("sup.wes"), waiting["id"])) == ["dwg", "purge", "vt", "tag"]
stage = one(
shop.settings,
"SELECT checklist_template FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL",
(later["id"],),
)
assert stage is not None and [i["key"] for i in stage[0]] == ["dwg", "purge", "vt"]
# completeness reads the frozen list: three ticks, a weld and the filler let it submit
out = tick_all(shop, later, keys=("dwg", "purge", "vt"))
out = set_filler(shop, add_weld(shop, out))
assert "submit" in fresh(andy, out["id"])["can"]
# the started packet still needs the default keys
assert "submit" not in fresh(andy, started["id"])["can"]
# the data book's checklist section reads the frozen list too
with shop_conn(shop) as conn:
t = checklists.stage_templates(conn, stage_id(shop, later["id"]), "03")
assert [i["key"] for i in t["weld"]] == ["dwg", "purge", "vt"]
live = checklists.stage_templates(conn, None, "03")
assert [i["key"] for i in live["weld"]] == ["dwg", "purge", "vt", "tag"]
def stage_id(shop: Shop, packet_id: int) -> int:
row = one(shop.settings, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (packet_id,))
assert row is not None
return int(row[0])
def shop_conn(shop: Shop) -> Any:
from pshelpers import app_conn
return app_conn(shop.settings)