Files
PacketStation/tests/server/test_itp.py
T
spencermandClaude Fable 5.1 00531cb329
ci / frontend (push) Successful in 37s
ci / server (push) Successful in 9m18s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 2m17s
Phase 6: Q.C. inspection, project folders, packet documents and the digital ITP
The Q.C. screen (KPIs, boards, project folders), the Q.C. tab on the packet
page (check items from the qc_check_items settings row, visual exam per weld,
NDE, hardness with the NACE label, notes), accept with a route and a
signature gated by qc_missing_empty, reject with a reason or an NCR number
closing the inspection into qc_history, Q.C. folder documents per project and
unit with superseding revisions, packet documents as a closed set with
take-out reasons, the digital ITP per unit from the frozen template (line
initials with CAS, unsign, N/A, customer codes, rejects with NCR, block
signatures, sign-off with the PDF hook, admin reopen, the ITP log), hold-point
warnings by department page group, the scoped policy check for unit and
project writes, migration 0007, tests, Playwright scenarios and docs.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-17 20:08:53 -06:00

551 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase 6 digital ITP through the API: start from the frozen template, line initials with their compare-and-set,
unsign by the same inspector or an admin, N/A on a line and a section, customer codes and witnesses, reject and the
NCR, block fields and signatures, sign-off with the PDF hook, the lock after sign-off, reopen, and the log texts.
The directory adds two people to the fakedir roster through this module's fixture: qc.sue (a second Q.C. user) and
qcsup.sam (a Q.C. supervisor). fakedir.py itself has neither."""
from __future__ import annotations
import uuid
from collections.abc import Iterator
from typing import Any
import pytest
from pshelpers import needs_pg
from qchelpers import (
MSG_BLOCK_NOT_READY,
MSG_BLOCK_SIGNED,
MSG_BLOCKS_UNSIGNED,
MSG_ITP_COMPLETE,
MSG_NCR,
MSG_NCR_REQUIRED,
MSG_UNSIGN,
NCR,
NCR2,
SECTION_SIZES,
SECTION_TITLE_5,
TEMPLATE_ITEMS,
TEMPLATE_VER,
approve_all,
block,
enter_ncr,
get_itp,
item_keys,
itp_log,
line,
na_lines,
open_qc_shop,
patch_block,
patch_hook,
patch_itp,
patch_line,
qc_directory,
reject_line,
reopen_itp,
section_keys,
section_na,
sign_all_blocks,
sign_block,
sign_line,
sign_off,
start_itp,
today_iso,
today_us,
unsign_block,
unsign_line,
upload_doc,
)
from shophelpers import IT, Shop, all_rows, make_unit, ok, one, refused
from packetstation.settings import Settings
pytestmark = needs_pg
@pytest.fixture
def qc_shop(pg_settings: Settings, dev_visual: None) -> Iterator[Shop]:
"""A Shop on a directory that also has qc.sue (Q.C.) and qcsup.sam (Q.C. supervisor)."""
yield from open_qc_shop(pg_settings, qc_directory())
def numbers(shop: Shop, unit_id: int) -> tuple[str, str]:
row = one(
shop.settings,
"SELECT p.number, p.number || u.lot FROM units u JOIN projects p ON p.id = u.project_id WHERE u.id = %s",
(unit_id,),
)
return str(row[0]), str(row[1])
def new_itp(shop: Shop) -> tuple[int, dict[str, Any]]:
_, unit_id = make_unit(shop.settings)
return unit_id, start_itp(shop, unit_id)
def done_section(shop: Shop, itp: dict[str, Any], n: str) -> dict[str, Any]:
"""The first line of section n approved, the rest N/A: its block is ready."""
keys = section_keys(itp, n)
itp = ok(sign_line(shop("qc.quinn"), itp, keys[0]))
return na_lines(shop, itp, keys[1:])
# ================================================================ start
def test_start(qc_shop: Shop) -> None:
shop = qc_shop
from packetstation.domain.itp_template import ITP_TEMPLATE
project_id, unit_id = make_unit(shop.settings)
pno, uno = numbers(shop, unit_id)
empty = get_itp(shop("qc.quinn"), unit_id)
assert empty["itp"] is None and empty["unit"]["id"] == unit_id and empty["unit"]["number"] == uno
assert shop("qc.quinn").get("/api/units/999999/itp").status_code == 404
refused(shop("wh.wade").post(f"/api/units/{unit_id}/itp", json={}), 403, "forbidden")
refused(shop("clerk.clara").post(f"/api/units/{unit_id}/itp", json={}), 403, "forbidden")
itp = start_itp(shop, unit_id)
assert itp["unit_id"] == unit_id and itp["no"] == f"{uno}-ITP" and itp["status"] == "open"
assert itp["hdr"] == {"client": "Acme Refining", "pname": "Shop project", "pno": pno, "po": "PO-55", "unit": uno}
assert itp["template"] == ITP_TEMPLATE and itp["template"]["ver"] == TEMPLATE_VER
assert len(item_keys(itp)) == TEMPLATE_ITEMS
assert {str(s["n"]): len(s["items"]) for s in itp["template"]["sections"]} == SECTION_SIZES
assert itp["pages"] == [
{"code": "WP", "name": "Weld Parts"},
{"code": "C", "name": "Code"},
{"code": "FA", "name": "Final Assembly"},
]
assert set(itp["lines"]) == set(item_keys(itp))
assert all(ln["result"] is None and ln["na"] is False for ln in itp["lines"].values())
assert itp["progress"] == {"done": 0, "total": TEMPLATE_ITEMS, "holds": 17}
assert len(itp["holds"]) == 17
assert [b["key"] for b in itp["blocks"]] == ["3", "4", "5", "6", "7", "8", "9"]
assert block(itp, "3")["sections"] == ["1", "2", "3"] and block(itp, "3")["scope"] == "Sections 1–3"
assert block(itp, "4")["scope"] == "Section 4" and block(itp, "9")["final"] is True
assert not any(b["signed"] or b["ready"] for b in itp["blocks"])
assert itp["signoff"] is None and itp["reopen"] is None
assert itp["created_by"]["display_name"] == "Quinn QC" and itp["created_at"]
assert itp["can"] == {"edit": True, "sign_off": False, "reopen": False}
assert itp_log(itp) == [("ITP started", f"{uno}-ITP · template {TEMPLATE_VER}")]
# the template is frozen in the row
row = one(shop.settings, "SELECT template, no FROM itps WHERE id = %s", (itp["id"],))
assert row[0] == ITP_TEMPLATE and row[1] == f"{uno}-ITP"
# a second start
refused(shop("qc.quinn").post(f"/api/units/{unit_id}/itp", json={}), 409, "itp_exists")
# other people read it, without edit rights
other = get_itp(shop("wh.wade"), unit_id)
assert other["id"] == itp["id"] and other["can"]["edit"] is False
# the audit row
au = one(shop.settings, "SELECT subject_type, subject_id, detail FROM audit_events WHERE action = 'itp.itp_start'")
assert au is not None and au[0] == "itp" and str(au[1]) == str(itp["id"])
assert au[2]["text"] == "ITP started" and au[2]["unit_id"] == unit_id
del project_id
def test_header_fields(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
v = itp["version"]
itp = ok(patch_itp(q, itp, {"client": "ACME"}, {"client": "Acme Refining"}))
assert itp["hdr"]["client"] == "ACME" and itp["version"] == v + 1
assert itp_log(itp)[0] == ("ITP header changed", "client: ACME")
itp = ok(patch_itp(q, itp, {"no": "L9-ITP-B"}))
assert itp["no"] == "L9-ITP-B" and itp_log(itp)[0][0] == "ITP plan no. changed"
r = refused(patch_itp(q, itp, {"client": "Other"}, {"client": "Acme Refining"}), 409, "field_conflict")
assert r["current"] == {"client": "ACME"}
refused(patch_itp(shop("clerk.clara"), itp, {"client": "X"}), 403, "forbidden")
# ================================================================ lines
def test_line_sign_and_cas(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
itp = ok(sign_line(q, itp, "1.1"))
ln = line(itp, "1.1")
assert ln["result"] == "approve" and ln["by"]["display_name"] == "Quinn QC" and ln["initials"] == "QQ"
assert ln["at"] and itp["progress"]["done"] == 1
assert itp_log(itp)[0][0] == "ITP line 1.1 approved" and not itp_log(itp)[0][1]
refused(sign_line(q, itp, "1.1"), 409, "already_signed")
# a stale If-Match
rv = line(itp, "1.2")["row_version"]
refused(sign_line(q, itp, "1.2", if_match=rv + 5), 409, "row_conflict")
itp = ok(sign_line(q, itp, "1.2", if_match=rv))
assert line(itp, "1.2")["row_version"] > rv
# another Q.C. user signs with their own initials
itp = ok(sign_line(shop("qc.sue"), itp, "1.3"))
assert line(itp, "1.3")["initials"] == "SS" and line(itp, "1.3")["by"]["display_name"] == "Sue Second"
refused(sign_line(shop("sup.wes"), itp, "1.4"), 403, "forbidden")
# a key that is not in the template
assert sign_line(q, itp, "9.99").status_code in (404, 422)
def test_line_unsign(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
itp = ok(sign_line(q, itp, "1.1"))
# another Q.C. user may not remove it
refused(unsign_line(shop("qc.sue"), itp, "1.1"), 403, "forbidden", MSG_UNSIGN)
itp = ok(unsign_line(q, itp, "1.1"))
assert line(itp, "1.1")["result"] is None and line(itp, "1.1")["by"] is None
assert itp_log(itp)[0] == ("ITP line 1.1 initial removed", f"was Quinn QC {today_us()}")
# an admin, with an IT reason
itp = ok(sign_line(q, itp, "1.1"))
refused(unsign_line(shop("admin.ann"), itp, "1.1"), 400, "it_reason_required")
itp = ok(unsign_line(shop("admin.ann"), itp, "1.1", it_reason=IT))
assert line(itp, "1.1")["result"] is None
log_row = itp["log"][0]
assert log_row["by"]["display_name"] == "Ann Admin"
# refused once the block is signed
itp = done_section(shop, itp, "4")
first = section_keys(itp, "4")[0]
itp = ok(sign_block(q, itp, "4"))
refused(unsign_line(q, itp, first), 409, "block_signed", MSG_BLOCK_SIGNED)
# an open line has nothing to remove
r = unsign_line(q, itp, "1.5")
assert r.status_code in (404, 409, 422), r.text
def test_line_na_and_section_na(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
itp = ok(patch_line(q, itp, "1.2", na=True))
assert line(itp, "1.2")["na"] is True and itp["progress"]["done"] == 1
assert itp_log(itp)[0] == ("ITP line 1.2 updated", "marked N/A")
refused(sign_line(q, itp, "1.2"), 409, "line_na")
itp = ok(patch_line(q, itp, "1.2", na=False))
assert line(itp, "1.2")["na"] is False and itp_log(itp)[0] == ("ITP line 1.2 updated", "applies again")
itp = ok(sign_line(q, itp, "1.1"))
refused(patch_line(q, itp, "1.1", na=True), 409, "already_signed")
# a whole section
v = itp["version"]
itp = ok(section_na(q, itp, "5"))
assert all(line(itp, k)["na"] for k in section_keys(itp, "5"))
assert itp_log(itp)[0] == ("ITP section 5 marked N/A", SECTION_TITLE_5) and itp["version"] > v
assert block(itp, "5")["ready"] is True and block(itp, "5")["all_na"] is True
itp = ok(section_na(q, itp, "5", na=False))
assert not any(line(itp, k)["na"] for k in section_keys(itp, "5"))
assert itp_log(itp)[0] == ("ITP section 5 applies again", SECTION_TITLE_5)
# refused once a line of the section is approved
refused(section_na(q, itp, "1"), 409, "already_signed")
refused(section_na(shop("clerk.clara"), itp, "5"), 403, "forbidden")
def test_line_customer_and_note(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
refused(patch_line(q, itp, "1.3", cust_code="X"), 422, "invalid_field")
itp = ok(patch_line(q, itp, "1.3", cust_code="W"))
assert line(itp, "1.3")["cust_code"] == "W"
assert itp_log(itp)[0] == ("ITP line 1.3 updated", "customer code W")
itp = ok(patch_line(q, itp, "1.3", cust_code=""))
assert not line(itp, "1.3")["cust_code"]
assert itp_log(itp)[0] == ("ITP line 1.3 updated", "customer code as template")
itp = ok(patch_line(q, itp, "1.3", cust_by="Rick Inspector"))
ln = line(itp, "1.3")
assert ln["cust_by"] == "Rick Inspector" and ln["cust_at"] == today_iso()
assert ln["cust_rec"]["display_name"] == "Quinn QC"
assert itp_log(itp)[0] == ("ITP line 1.3 updated", "customer Rick Inspector")
itp = ok(patch_line(q, itp, "1.3", cust_by="Rick Inspector", cust_at="2026-10-01"))
assert line(itp, "1.3")["cust_at"] == "2026-10-01"
itp = ok(patch_line(q, itp, "1.3", cust_by=""))
assert not line(itp, "1.3")["cust_by"] and line(itp, "1.3")["cust_at"] is None
assert itp_log(itp)[0] == ("ITP line 1.3 updated", "customer cleared")
itp = ok(patch_line(q, itp, "1.3", cust_code="H", note="Reviewed WPS-101 R2"))
assert line(itp, "1.3")["note"] == "Reviewed WPS-101 R2"
assert itp_log(itp)[0] == ("ITP line 1.3 updated", "customer code H, note")
for code in ("R", "R/AU", "AU", "W", "H", "NA"):
assert ok(patch_line(q, itp, "1.4", cust_code=code))["lines"]["1.4"]["cust_code"] == code
# nothing changed: no log, no version bump
itp = ok(patch_line(q, itp, "1.4", cust_code="NA"))
before = (len(itp["log"]), itp["version"])
itp = ok(patch_line(q, itp, "1.4", cust_code="NA"))
assert (len(itp["log"]), itp["version"]) == before
# limits
r = patch_line(q, itp, "1.5", note="x" * 501)
assert r.status_code == 422 or len(r.json()["lines"]["1.5"]["note"]) <= 500, r.text
def test_reject_and_ncr(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
# reject without an NCR
itp = ok(reject_line(q, itp, "1.4", note="Manual is rev C"))
ln = line(itp, "1.4")
assert ln["result"] == "reject" and ln["ncr"] is None and ln["ncr_missing"] is True
assert [r["kind"] for r in ln["rejects"]] == ["reject"] and ln["rejects"][0]["note"] == "Manual is rev C"
assert itp_log(itp)[0] == ("ITP line 1.4 rejected", "NCR # pending · Manual is rev C")
assert any(h["n"] == "1.4" and h["rejected"] for h in itp["holds"])
refused(sign_line(q, itp, "1.4"), 409, "ncr_required", MSG_NCR_REQUIRED)
refused(patch_line(q, itp, "1.4", na=True), 409, "line_rejected")
r = reject_line(q, itp, "1.4", note="again")
assert r.status_code == 409, r.text
# the NCR later
refused(enter_ncr(q, itp, "1.4", "NCR-26-1"), 422, "invalid_ncr", MSG_NCR)
itp = ok(enter_ncr(q, itp, "1.4", " ncr-2026-0021"))
ln = line(itp, "1.4")
assert ln["ncr"] == NCR and ln["ncr_missing"] is False
assert [r["kind"] for r in ln["rejects"]] == ["reject", "ncr"]
assert itp_log(itp)[0] == ("ITP line 1.4 NCR number entered", f"NCR {NCR}")
refused(enter_ncr(q, itp, "1.4", NCR2), 409, "no_open_reject")
refused(enter_ncr(q, itp, "1.5", NCR2), 409, "no_open_reject")
itp = ok(sign_line(q, itp, "1.4"))
assert line(itp, "1.4")["result"] == "approve"
assert itp_log(itp)[0] == ("ITP line 1.4 approved", f"after NCR {NCR}")
assert not any(h["n"] == "1.4" for h in itp["holds"])
# the reject history survives the approve
assert len(line(itp, "1.4")["rejects"]) == 2
rows = all_rows(shop.settings, "SELECT kind, ncr FROM itp_rejects WHERE itp_id = %s ORDER BY id", (itp["id"],))
assert [tuple(r) for r in rows] == [("reject", None), ("ncr", NCR)]
# reject with the NCR at once
itp = ok(reject_line(q, itp, "1.5", ncr="ncr-2026-0022", note="Map missing W4"))
assert line(itp, "1.5")["ncr"] == NCR2 and line(itp, "1.5")["ncr_missing"] is False
assert itp_log(itp)[0] == ("ITP line 1.5 rejected", f"NCR {NCR2} · Map missing W4")
itp = ok(sign_line(q, itp, "1.5"))
assert itp_log(itp)[0] == ("ITP line 1.5 approved", f"after NCR {NCR2}")
# refused on an approved line and on an N/A line
assert reject_line(q, itp, "1.5", note="x").status_code == 409
itp = ok(patch_line(q, itp, "1.6", na=True))
assert reject_line(q, itp, "1.6", note="x").status_code == 409
refused(reject_line(q, itp, "1.7", ncr="bad one"), 422, "invalid_ncr")
# ================================================================ blocks
def test_block_fields(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
itp = ok(patch_block(q, itp, "3", qn="QN-17"))
assert block(itp, "3")["qn"] == "QN-17" and itp_log(itp)[0] == ("ITP block 3 QN # changed", "QN-17")
itp = ok(patch_block(q, itp, "3", remarks="Fit-up per drawing"))
assert itp_log(itp)[0] == ("ITP block 3 remarks changed", "Fit-up per drawing")
itp = ok(patch_block(q, itp, "3", cust_name="Rick Inspector"))
b = block(itp, "3")
assert b["cust_name"] == "Rick Inspector" and b["cust_at"] == today_iso()
assert b["cust_by"]["display_name"] == "Quinn QC" and b["signed"] is False
assert itp_log(itp)[0] == ("ITP block 3 customer complete recorded", "Rick Inspector")
itp = ok(patch_block(q, itp, "3", cust_clear=True))
b = block(itp, "3")
assert not b["cust_name"] and b["cust_at"] is None and b["cust_by"] is None
assert itp_log(itp)[0][0] == "ITP block 3 customer complete cleared"
assert b["qn"] == "QN-17"
refused(patch_block(shop("sup.wes"), itp, "3", qn="QN-1"), 403, "forbidden")
def test_block_sign_gate(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
refused(sign_block(q, itp, "4"), 409, "block_not_ready", MSG_BLOCK_NOT_READY)
itp = done_section(shop, itp, "4")
assert block(itp, "4")["ready"] is True and block(itp, "4")["all_na"] is False
r = refused(sign_block(q, itp, "4", sign=None), 400, "signature_required")
assert r.get("meaning") == "Sign an ITP block"
r = sign_block(q, itp, "4", sign={"factor": "password", "secret": "wrong-one"})
assert 400 <= r.status_code < 500, r.text
itp = ok(sign_block(q, itp, "4"))
b = block(itp, "4")
assert b["signed"] is True and b["signed_by"]["display_name"] == "Quinn QC" and b["signed_at"]
assert itp["log"][0]["action"] == "ITP block 4 signed" and itp["log"][0]["note"] == "Section 4"
assert itp["log"][0]["signed"] is True
refused(sign_block(q, itp, "4"), 409, "already_signed")
sig = one(
shop.settings, "SELECT subject_type, subject_id, packet_id, meaning FROM signatures ORDER BY id DESC LIMIT 1"
)
assert (sig[0], str(sig[1]), sig[2], sig[3]) == ("itp", str(itp["id"]), None, "Sign an ITP block")
# a block of all N/A lines signs
itp = ok(section_na(q, itp, "5"))
itp = ok(sign_block(q, itp, "5"))
assert block(itp, "5")["signed"] is True and block(itp, "5")["all_na"] is True
# block 3 covers sections 1 to 3
itp = ok(section_na(q, itp, "2"))
itp = ok(section_na(q, itp, "3"))
assert block(itp, "3")["ready"] is False
itp = ok(section_na(q, itp, "1"))
assert block(itp, "3")["ready"] is True
itp = ok(sign_block(q, itp, "3"))
assert itp["log"][0]["note"] == "Sections 1–3"
refused(sign_block(shop("sup.wes"), itp, "6"), 403, "forbidden")
def test_block_unsign(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
itp = done_section(shop, itp, "4")
itp = ok(patch_block(q, itp, "4", qn="QN-4"))
itp = ok(sign_block(q, itp, "4"))
refused(unsign_block(shop("qc.sue"), itp, "4"), 403, "forbidden")
itp = ok(unsign_block(q, itp, "4"))
b = block(itp, "4")
assert b["signed"] is False and b["signed_by"] is None and b["signed_at"] is None and b["qn"] == "QN-4"
assert itp_log(itp)[0][0] == "ITP block 4 signature removed"
# an admin may, with an IT reason
itp = ok(sign_block(q, itp, "4"))
itp = ok(unsign_block(shop("admin.ann"), itp, "4", it_reason=IT))
assert block(itp, "4")["signed"] is False
# the line can be unsigned again now
itp = ok(unsign_line(q, itp, section_keys(itp, "4")[0]))
# ================================================================ sign-off, lock, reopen
def finish_all(shop: Shop, itp: dict[str, Any]) -> dict[str, Any]:
return sign_all_blocks(shop, approve_all(shop, itp))
def test_signoff_without_pdf(qc_shop: Shop) -> None:
shop = qc_shop
unit_id, itp = new_itp(shop)
q = shop("qc.quinn")
refused(sign_off(q, itp), 409, "blocks_unsigned", MSG_BLOCKS_UNSIGNED)
itp = approve_all(shop, itp)
assert itp["progress"] == {"done": TEMPLATE_ITEMS, "total": TEMPLATE_ITEMS, "holds": 0}
assert itp["holds"] == [] and itp["can"]["sign_off"] is False
blocks = [str(b["key"]) for b in itp["blocks"]]
for key in blocks[:-1]:
itp = ok(sign_block(q, itp, key))
refused(sign_off(q, itp), 409, "blocks_unsigned")
itp = ok(sign_block(q, itp, blocks[-1]))
assert itp["can"]["sign_off"] is True
r = refused(sign_off(q, itp, sign=None), 400, "signature_required")
assert r.get("meaning") == "Sign off the ITP"
refused(sign_off(shop("clerk.clara"), itp), 403, "forbidden")
itp = ok(sign_off(q, itp))
assert itp["status"] == "complete" and itp["can"] == {"edit": False, "sign_off": False, "reopen": False}
assert itp["signoff"]["by"]["display_name"] == "Quinn QC" and itp["signoff"]["at"]
assert itp["signoff"]["asset_id"] is None
assert itp_log(itp)[0] == ("ITP signed off", "PDF filing reserved for phase 7") and itp["log"][0]["signed"]
row = one(
shop.settings, "SELECT signoff_asset_id, signoff_signature_id IS NOT NULL FROM itps WHERE id = %s", (itp["id"],)
)
assert tuple(row) == (None, True)
assert all_rows(shop.settings, "SELECT count(*) FROM qc_docs WHERE unit_id = %s", (unit_id,))[0][0] == 0
# the admin may reopen; qc.quinn and qc.sue may not
assert get_itp(shop("admin.ann"), unit_id)["can"]["reopen"] is True
assert get_itp(q, unit_id)["can"]["reopen"] is False
def test_locked_after_signoff_and_reopen(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
itp = ok(sign_off(q, finish_all(shop, itp)))
first = section_keys(itp, "1")[0]
for r in (
patch_itp(q, itp, {"client": "X"}),
sign_line(q, itp, first),
unsign_line(q, itp, first),
patch_line(q, itp, first, note="late"),
section_na(q, itp, "5"),
reject_line(q, itp, first, ncr=NCR),
enter_ncr(q, itp, first, NCR),
patch_block(q, itp, "3", qn="QN-9"),
sign_block(q, itp, "3"),
unsign_block(q, itp, "3"),
sign_off(q, itp),
):
refused(r, 409, "itp_complete", MSG_ITP_COMPLETE)
# reopen: Q.C. users may not; the Q.C. supervisor and the admin may
refused(reopen_itp(q, itp, "Customer asked for 4.2 again"), 403, "forbidden")
refused(reopen_itp(shop("qc.sue"), itp, "Customer asked for 4.2 again"), 403, "forbidden")
r = reopen_itp(shop("admin.ann"), itp, "short")
assert r.status_code == 400 and r.json()["code"] in ("reason_required", "it_reason_required"), r.text
itp = ok(reopen_itp(shop("admin.ann"), itp, "Customer asked for 4.2 again"))
assert itp["status"] == "open" and itp["reopen"]["reason"] == "Customer asked for 4.2 again"
assert itp["reopen"]["by"]["display_name"] == "Ann Admin" and itp["reopen"]["at"]
assert itp_log(itp)[0] == ("ITP reopened", "Customer asked for 4.2 again")
au = one(shop.settings, "SELECT it_reason FROM audit_events WHERE action = 'itp.itp_reopen'")
assert au is not None and au[0] == "Customer asked for 4.2 again"
# the signatures stay; the inspector undoes what must change
assert all(b["signed"] for b in itp["blocks"]) and itp["can"]["sign_off"] is True
refused(reopen_itp(shop("admin.ann"), itp, "Customer asked again"), 409, "itp_open")
itp = ok(patch_itp(q, itp, {"client": "ACME"}))
# a Q.C. supervisor reopens too
itp = ok(sign_off(q, itp))
itp = ok(reopen_itp(shop("qcsup.sam"), itp, "Re-inspect the nozzle welds"))
assert itp["status"] == "open" and itp["reopen"]["by"]["display_name"] == "Sam Qcsup"
def test_signoff_with_pdf_hook(qc_shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
shop = qc_shop
unit_id, itp = new_itp(shop)
q = shop("qc.quinn")
calls: list[Any] = []
asset_ids = [upload_doc(q, "itp-1.pdf")["id"], upload_doc(q, "itp-2.pdf")["id"]]
def fake_hook(conn: Any, itp_row: Any, actor: Any) -> uuid.UUID:
calls.append(itp_row)
return uuid.UUID(asset_ids[len(calls) - 1])
patch_hook(monkeypatch, fake_hook)
itp = ok(sign_off(q, finish_all(shop, itp)))
assert len(calls) == 1
assert itp["signoff"]["asset_id"] == asset_ids[0]
assert itp_log(itp)[0] == ("ITP signed off", "PDF filed")
row = one(shop.settings, "SELECT signoff_asset_id FROM itps WHERE id = %s", (itp["id"],))
assert str(row[0]) == asset_ids[0]
docs = all_rows(
shop.settings,
"SELECT id, type, title, coalesce(rev, ''), note, auto, superseded_by, asset_id"
" FROM qc_docs WHERE unit_id = %s",
(unit_id,),
)
assert len(docs) == 1
d = docs[0]
assert (d[1], d[2], d[3], d[5], d[6]) == ("ITP", f"{itp['no']} — signed off", "", True, None)
assert d[4] == "Filed automatically when the digital ITP was signed off." and str(d[7]) == asset_ids[0]
# a second sign-off (after a reopen) supersedes the earlier auto document
itp = ok(reopen_itp(shop("admin.ann"), itp, "Customer asked for 4.2 again"))
itp = ok(sign_off(q, itp))
assert itp["signoff"]["asset_id"] == asset_ids[1]
docs = all_rows(
shop.settings, "SELECT id, superseded_by, asset_id FROM qc_docs WHERE unit_id = %s ORDER BY id", (unit_id,)
)
assert len(docs) == 2 and docs[0][1] == docs[1][0] and docs[1][1] is None
def test_log_order_and_filed_packet(qc_shop: Shop) -> None:
"""The ITP belongs to the unit: a filed packet in the unit does not stop any ITP write. The log is newest
first, at most 600 rows, and every write wrote one row."""
shop = qc_shop
from qchelpers import filed_packet
_, unit_id = make_unit(shop.settings)
filed_packet(shop, unit_id)
itp = start_itp(shop, unit_id)
q = shop("qc.quinn")
itp = ok(sign_line(q, itp, "1.1"))
itp = ok(patch_line(q, itp, "1.2", na=True))
itp = ok(reject_line(q, itp, "1.3", ncr=NCR))
itp = ok(patch_block(q, itp, "3", qn="QN-1"))
itp = ok(patch_itp(q, itp, {"po": "PO-99"}))
assert [a for a, _ in itp_log(itp)] == [
"ITP header changed",
"ITP block 3 QN # changed",
"ITP line 1.3 rejected",
"ITP line 1.2 updated",
"ITP line 1.1 approved",
"ITP started",
]
ids = [r["id"] for r in itp["log"]]
assert ids == sorted(ids, reverse=True)
assert all(r["by"]["display_name"] == "Quinn QC" and r["by_name"] == "Quinn QC" for r in itp["log"])
count = all_rows(shop.settings, "SELECT count(*) FROM itp_log WHERE itp_id = %s", (itp["id"],))[0][0]
assert count == 6
# the rest of the ITP still works next to the filed packet: unsign, sign a block, sign off
itp = ok(unsign_line(q, itp, "1.1"))
itp = ok(sign_off(q, finish_all(shop, itp)))
assert itp["status"] == "complete"