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]>
256 lines
11 KiB
Python
256 lines
11 KiB
Python
"""Phase 7 printed copies and exports through the API: every print kind with its stamp on every page, the print log
|
|
and the audit, one sheet, a document print and a foreign asset, nothing to print, the data book before complete,
|
|
the prints list, the ITP print and PDF, the exports, the signed-out call, the PM role, a removed packet, and the
|
|
append-only print log."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterator
|
|
from typing import Any
|
|
|
|
import psycopg
|
|
import pytest
|
|
from closehelpers import (
|
|
at_complete,
|
|
flat,
|
|
is_pdf,
|
|
page_texts,
|
|
pdf_pages,
|
|
q1,
|
|
qall,
|
|
remove_by_sql,
|
|
rich_complete,
|
|
)
|
|
from fakedir import FakeDirectory
|
|
from fastapi.testclient import TestClient
|
|
from pshelpers import ORIGIN_HEADERS, app_conn, needs_pg
|
|
from qchelpers import approve_all, at_qc, sign_all_blocks, sign_off, start_itp, upload_doc
|
|
from shophelpers import Shop, draft, ok, open_shop, refused
|
|
|
|
from packetstation.settings import Settings
|
|
|
|
pytestmark = needs_pg
|
|
|
|
WHAT = {
|
|
"traveler": "Job traveler",
|
|
"map-h": "Drawing with heat map",
|
|
"map-w": "Drawing with weld map",
|
|
"clean": "Drawing (no markup)",
|
|
"void": "Superseded drawings (VOID)",
|
|
"databook": "Data book",
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
|
yield from open_shop(pg_settings, fake_dir)
|
|
|
|
|
|
def get_pdf(c: TestClient, url: str) -> bytes:
|
|
r = c.get(url)
|
|
assert r.status_code == 200, (url, r.text[:500])
|
|
assert r.headers["content-type"].startswith("application/pdf"), url
|
|
assert r.headers.get("x-content-type-options") == "nosniff"
|
|
assert r.headers.get("cache-control") == "no-store"
|
|
assert r.headers["content-disposition"].startswith("inline;"), r.headers["content-disposition"]
|
|
assert is_pdf(r.content)
|
|
return r.content
|
|
|
|
|
|
def assert_print_stamp(data: bytes, what: str, by: str = "Clara Clerk (4001)") -> None:
|
|
for i, t in enumerate(page_texts(data)):
|
|
ft = flat(t)
|
|
assert f"PRINTED COPY - {what} - printed by {by}" in ft, (i, what, ft[-400:])
|
|
assert "verify revision before use" in ft, (i, what)
|
|
|
|
|
|
def last_print(settings: Settings, pid: int) -> Any:
|
|
return q1(
|
|
settings,
|
|
"SELECT kind, what, pages, by_name, stamp_sig, stamp, asset_id FROM print_log WHERE packet_id = %s"
|
|
" ORDER BY id DESC LIMIT 1",
|
|
(pid,),
|
|
)
|
|
|
|
|
|
def last_audit(settings: Settings, pid: int, action: str) -> Any:
|
|
row = q1(
|
|
settings,
|
|
"SELECT detail, user_id FROM audit_events WHERE action = %s AND packet_id = %s ORDER BY id DESC LIMIT 1",
|
|
(action, pid),
|
|
)
|
|
assert row is not None, (action, pid)
|
|
return row
|
|
|
|
|
|
def test_every_print_kind(shop: Shop) -> None:
|
|
pkt = rich_complete(shop)
|
|
pid = pkt["id"]
|
|
c = shop("clerk.clara")
|
|
for kind, what in WHAT.items():
|
|
data = get_pdf(c, f"/api/packets/{pid}/print/{kind}")
|
|
assert_print_stamp(data, what)
|
|
row = last_print(shop.settings, pid)
|
|
assert (row[0], row[1], row[2], row[3]) == (kind, what, pdf_pages(data), "Clara Clerk"), row
|
|
assert row[4] and len(row[4]) == 16 and row[4] in row[5]
|
|
au = last_audit(shop.settings, pid, "print")
|
|
assert au[0]["kind"] == kind and au[0]["what"] == what and au[0]["pages"] == pdf_pages(data)
|
|
assert au[0]["stamp_sig"] == row[4]
|
|
# page counts: two live sheets, one void sheet
|
|
assert pdf_pages(get_pdf(c, f"/api/packets/{pid}/print/clean")) == 3
|
|
assert pdf_pages(get_pdf(c, f"/api/packets/{pid}/print/void")) == 1
|
|
# one sheet
|
|
for kind in ("map-h", "map-w", "clean"):
|
|
data = get_pdf(c, f"/api/packets/{pid}/print/{kind}?sheet=2")
|
|
assert pdf_pages(data) == 1
|
|
assert_print_stamp(data, f"{WHAT[kind]} (sheet 2)")
|
|
assert last_print(shop.settings, pid)[1] == f"{WHAT[kind]} (sheet 2)"
|
|
# the download form
|
|
r = c.get(f"/api/packets/{pid}/print/traveler?download=1")
|
|
assert r.status_code == 200 and r.headers["content-disposition"].startswith("attachment;")
|
|
assert '-Lot001 Job traveler.pdf"' in r.headers["content-disposition"], r.headers["content-disposition"]
|
|
|
|
|
|
def test_document_prints(shop: Shop) -> None:
|
|
pkt = rich_complete(shop)
|
|
pid = pkt["id"]
|
|
c = shop("clerk.clara")
|
|
mtr = next(b for b in pkt["bom"] if b["item"] == 1)
|
|
mtr_id = (mtr.get("mtr") or {}).get("asset_id") or mtr.get("mtr_asset_id")
|
|
png = next(b for b in pkt["bom"] if b["item"] == 2)
|
|
png_id = (png.get("mtr") or {}).get("asset_id") or png.get("mtr_asset_id")
|
|
doc = next(d for d in pkt["docs"] if d.get("removed") is None)
|
|
doc_id = doc.get("asset_id") or doc["url"].rsplit("/", 1)[-1]
|
|
folder = pkt["qc_folder_docs"][0]
|
|
folder_id = folder.get("asset_id") or folder["url"].rsplit("/", 1)[-1]
|
|
for asset, what_starts in (
|
|
(mtr_id, "MTR - item 1"),
|
|
(png_id, "MTR - item 2"),
|
|
(doc_id, "RT report W1-W2"),
|
|
(folder_id, "WPS / PQR - Unit folder doc"),
|
|
):
|
|
data = get_pdf(c, f"/api/packets/{pid}/print/doc?asset={asset}")
|
|
row = last_print(shop.settings, pid)
|
|
assert row[0] == "doc" and row[1].startswith(what_starts) and str(row[6]) == str(asset), row
|
|
assert_print_stamp(data, row[1])
|
|
assert last_audit(shop.settings, pid, "print")[0].get("asset_id") in (str(asset), None)
|
|
# a PNG MTR prints on one letter page
|
|
assert pdf_pages(get_pdf(c, f"/api/packets/{pid}/print/doc?asset={png_id}")) == 1
|
|
# an asset that is not the packet's
|
|
foreign = upload_doc(c, "other.pdf")["id"]
|
|
refused(c.get(f"/api/packets/{pid}/print/doc?asset={foreign}"), 404, "not_found")
|
|
r = c.get(f"/api/packets/{pid}/print/doc")
|
|
assert r.status_code in (404, 422), r.text
|
|
r = c.get(f"/api/packets/{pid}/print/nonsense")
|
|
assert r.status_code in (404, 422), r.text
|
|
|
|
|
|
def test_nothing_to_print_and_wrong_status(shop: Shop) -> None:
|
|
pkt = at_qc(shop)
|
|
c = shop("clerk.clara")
|
|
refused(c.get(f"/api/packets/{pkt['id']}/print/void"), 404, "nothing_to_print", "Nothing to print.")
|
|
refused(c.get(f"/api/packets/{pkt['id']}/print/databook"), 409, "wrong_status")
|
|
refused(
|
|
c.get(f"/api/packets/{pkt['id']}/pdf/databook"),
|
|
409,
|
|
"wrong_status",
|
|
"The data book is built once the packet is back from Q.C.",
|
|
)
|
|
assert q1(shop.settings, "SELECT count(*) FROM print_log WHERE packet_id = %s", (pkt["id"],))[0] == 0
|
|
# the traveler prints in any status
|
|
get_pdf(c, f"/api/packets/{pkt['id']}/print/traveler")
|
|
|
|
|
|
def test_prints_list_newest_first_and_exports(shop: Shop) -> None:
|
|
pkt = at_complete(shop)
|
|
pid = pkt["id"]
|
|
c = shop("clerk.clara")
|
|
assert ok(c.get(f"/api/packets/{pid}/prints")) == {"prints": []}
|
|
get_pdf(c, f"/api/packets/{pid}/print/traveler")
|
|
get_pdf(shop("pm.paula"), f"/api/packets/{pid}/pdf/traveler")
|
|
data = get_pdf(shop("pm.paula"), f"/api/packets/{pid}/pdf/databook")
|
|
assert "not yet filed" in flat(page_texts(data)[0])
|
|
assert shop("pm.paula").get(f"/api/packets/{pid}/export/traceability.csv").status_code == 200
|
|
out = ok(shop("qc.quinn").get(f"/api/packets/{pid}/prints"))["prints"]
|
|
assert [p["kind"] for p in out] == ["csv", "databook-export", "traveler", "traveler"]
|
|
assert out[0]["by"]["display_name"] == "Paula PM" and out[-1]["by"]["display_name"] == "Clara Clerk"
|
|
assert out[1]["pages"] == pdf_pages(data) and out[1]["what"]
|
|
assert set(out[0]) >= {"id", "at", "by", "kind", "what", "pages"}
|
|
ids = [p["id"] for p in out]
|
|
assert ids == sorted(ids, reverse=True)
|
|
kinds = [
|
|
r[0]["kind"]
|
|
for r in qall(
|
|
shop.settings,
|
|
"SELECT detail FROM audit_events WHERE action = 'export' AND packet_id = %s ORDER BY id",
|
|
(pid,),
|
|
)
|
|
]
|
|
assert kinds == ["traveler", "databook", "csv"]
|
|
assert (
|
|
q1(shop.settings, "SELECT count(*) FROM audit_events WHERE action = 'print' AND packet_id = %s", (pid,))[0] == 1
|
|
)
|
|
# prints and exports write no packet log rows
|
|
actions = [r[0] for r in qall(shop.settings, "SELECT action FROM packet_log WHERE packet_id = %s", (pid,))]
|
|
assert not any("print" in a.lower() or "export" in a.lower() for a in actions)
|
|
|
|
|
|
def test_itp_print_and_pdf(shop: Shop) -> None:
|
|
pkt = at_complete(shop)
|
|
unit_id = pkt["unit"]["id"]
|
|
itp = start_itp(shop, unit_id)
|
|
q = shop("qc.quinn")
|
|
data = get_pdf(q, f"/api/itps/{itp['id']}/pdf")
|
|
assert "OPEN, not signed off" in flat(page_texts(data)[0])
|
|
row = q1(shop.settings, "SELECT kind, unit_id, itp_id, packet_id FROM print_log ORDER BY id DESC LIMIT 1")
|
|
assert tuple(row) == ("itp-pdf", unit_id, itp["id"], None)
|
|
r = q.get(f"/api/itps/{itp['id']}/pdf?download=1")
|
|
assert r.status_code == 200 and r.headers["content-disposition"].startswith("attachment;")
|
|
assert f"{itp['no']}.pdf" in r.headers["content-disposition"]
|
|
data = get_pdf(shop("pm.paula"), f"/api/itps/{itp['id']}/print")
|
|
for t in page_texts(data):
|
|
assert "PRINTED COPY" in flat(t) and "verify revision before use" in flat(t)
|
|
assert q1(shop.settings, "SELECT kind FROM print_log ORDER BY id DESC LIMIT 1")[0] == "itp"
|
|
itp = ok(sign_off(q, sign_all_blocks(shop, approve_all(shop, itp))))
|
|
data = get_pdf(q, f"/api/itps/{itp['id']}/pdf")
|
|
assert "signed off" in flat(page_texts(data)[0]) and "OPEN, not signed off" not in flat(page_texts(data)[0])
|
|
refused(q.get("/api/itps/999999/pdf"), 404, "not_found")
|
|
|
|
|
|
def test_signed_out_pm_removed_and_append_only(shop: Shop) -> None:
|
|
gone = draft(shop)
|
|
remove_by_sql(shop.settings, gone["id"])
|
|
pkt = at_complete(shop)
|
|
pid = pkt["id"]
|
|
anon = TestClient(shop.main.app, headers=ORIGIN_HEADERS)
|
|
for url in (
|
|
f"/api/packets/{pid}/pdf/databook",
|
|
f"/api/packets/{pid}/pdf/traveler",
|
|
f"/api/packets/{pid}/export/traceability.csv",
|
|
f"/api/packets/{pid}/print/traveler",
|
|
f"/api/packets/{pid}/prints",
|
|
):
|
|
assert anon.get(url).status_code == 401, url
|
|
# the PM role reads every one
|
|
pm = shop("pm.paula")
|
|
for kind in ("traveler", "map-h", "map-w", "clean", "databook"):
|
|
get_pdf(pm, f"/api/packets/{pid}/print/{kind}")
|
|
# a missing packet and a removed packet are 404
|
|
refused(pm.get("/api/packets/999999/print/traveler"), 404, "not_found")
|
|
# a fresh session: the PDFs above can take longer than the idle lock (15 minutes over the tunnel)
|
|
clara = shop.fresh("clerk.clara")
|
|
for url in (
|
|
f"/api/packets/{gone['id']}/print/traveler",
|
|
f"/api/packets/{gone['id']}/pdf/traveler",
|
|
f"/api/packets/{gone['id']}/export/traceability.csv",
|
|
):
|
|
refused(clara.get(url), 404, "not_found")
|
|
# the print log is append-only
|
|
with app_conn(shop.settings) as conn:
|
|
with pytest.raises(psycopg.errors.InsufficientPrivilege):
|
|
conn.execute("UPDATE print_log SET what = 'x' WHERE packet_id = %s", (pid,))
|
|
with app_conn(shop.settings) as conn:
|
|
with pytest.raises(psycopg.errors.InsufficientPrivilege):
|
|
conn.execute("DELETE FROM print_log WHERE packet_id = %s", (pid,))
|
|
assert q1(shop.settings, "SELECT count(*) FROM print_log WHERE packet_id = %s", (pid,))[0] == 5
|