162 lines
7.1 KiB
Python
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)
|