Clerical close and file: the VISUAL observed state beside the close confirm, a 409 with a second confirm when the feed still shows work orders open, filing with a signature and the HMAC seal, the data book built on the server and stored on the seal in the filing transaction, admin reopen keeping the broken seal with its data book. Server PDFs with reportlab, pypdf and pypdfium2: the manufacturing data book page for page after the old build (cover, index, drawings with VOID stamps, traceability and heat map rasters, weld maps and welding records, Q.C. inspections, Q.C. and test documents, MTRs, record history), the job traveler with work order QR codes, the ITP PDF filed automatically at sign-off, every print kind with the PRINTED COPY stamp, the controlled-copy stamp signed with the seal key, PDF metadata, the traceability CSV with the formula guard, and the append-only print_log with an audit row for every print and export (migration 0008). Frontend: the close and file panel, the print menu and its entry points, the printed copies list on the History tab, the ITP PDF buttons. Tests: server (close, file, reopen, seal, the builders, prints, CSV, stamps), frontend, and the Playwright close and print scenarios. Docs updated; SECURITY closes M2, S6, S7 and S8. Co-Authored-By: Claude Fable 5.1 <[email protected]>
557 lines
26 KiB
Python
557 lines
26 KiB
Python
"""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_files_pdf(qc_shop: Shop) -> None:
|
||
"""Phase 7: the sign-off builds the signed ITP PDF and files it in the unit's Q.C. folder."""
|
||
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"]
|
||
asset_id = itp["signoff"]["asset_id"]
|
||
assert asset_id is not None
|
||
assert itp_log(itp)[0] == ("ITP signed off", "PDF filed") 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 (str(row[0]), row[1]) == (asset_id, True)
|
||
asset = one(shop.settings, "SELECT mime, source, original_name FROM assets WHERE id = %s", (asset_id,))
|
||
assert tuple(asset) == ("application/pdf", "itp_signoff", f"{itp['no']}_signed.pdf")
|
||
docs = all_rows(shop.settings, "SELECT type, auto, asset_id FROM qc_docs WHERE unit_id = %s", (unit_id,))
|
||
assert [(d[0], d[1], str(d[2])) for d in docs] == [("ITP", True, asset_id)]
|
||
# 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, settings: Any = None) -> uuid.UUID:
|
||
assert settings is not None
|
||
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"
|