"""Helpers for the phase 6 Q.C. tests: a packet brought to status qc with two welds, the Q.C. inspection endpoints, accept and reject, the digital ITP, and Q.C. folder documents. Built on shophelpers (imported, never edited). The packet is the shop packet: a hand draft in 03 (drawing D-5001 rev B, WPS-11, three BOM lines with heats, two work orders), assigned to weld.andy, two welds, submitted, and sent to Q.C. by sup.wes with the book and the spool confirmed at `Bay 2`. """ from __future__ import annotations import importlib import uuid from collections.abc import Iterator from datetime import datetime from typing import Any from zoneinfo import ZoneInfo import pytest from fakedir import FakeDirectory, FakeUser from fastapi.testclient import TestClient from pkthelpers import step from shophelpers import ( SIGN, USERS, Shop, finish_stage, in_progress, ok, open_shop, submit, ) from packetstation.settings import Settings NCR = "NCR-2026-0021" NCR2 = "NCR-2026-0022" NCR_URL = "https://ncr.pescoinc.biz/ncrs/" MSG_NCR = "Enter an NCR number like NCR-2026-0021." MSG_ITP_COMPLETE = "The ITP is signed off and locked. An admin can reopen it." MSG_NCR_REQUIRED = "Enter the NCR # before approving this line." MSG_UNSIGN = "Only the inspector who initialed this line, or an admin, can remove it." MSG_BLOCK_SIGNED = "Undo the section signature first." MSG_BLOCK_NOT_READY = "Every line must be initialed or N/A first." MSG_BLOCKS_UNSIGNED = "Every section must be signed first." MSG_DOC_KIND = "Pick a document type from the list." MSG_TAKE_OUT = "Say why it comes out of the data book." MSG_NDE = "Pick an NDE method from the list." ROUTE_DB = "Clerical – Data Book" # the en dash is the old text TEMPLATE_VER = "ITP_Generation_Revised_2" QC_KEYS = ( "db_heats", "db_mtr", "db_weldmap", "db_wps", "db_ck", "ph_dim", "ph_heat", "ph_stamp", "ph_clean", "ph_qty", ) DOC_KINDS = { "nde": "NDE report (RT / UT / MT / PT)", "hydro": "Hydrotest report", "dimensional": "Dimensional report", "pmi": "PMI report", "coating": "Coating / paint report", "coc": "Certificate of conformance", "customer": "Customer inspection report", "ncr": "NCR", "other": "Other", } QC_TYPES = ( "ITP", "WPS / PQR", "NDE procedure", "Hydrotest procedure", "PMI / material", "Calibration certificate", "Customer spec", "Other", ) # The template, counted from src/js/itp.js line 4 (ITP_TMPL): 70 items in 9 sections. TEMPLATE_ITEMS = 70 SECTION_SIZES = {"1": 14, "2": 3, "3": 4, "4": 6, "5": 5, "6": 6, "7": 11, "8": 14, "9": 7} SECTION_PAGE = {"1": "WP", "2": "WP", "3": "WP", "4": "C", "5": "C", "6": "C", "7": "FA", "8": "FA", "9": "FA"} SECTION_TITLE_5 = "INTERNAL COATING" # Hold (H) and witness (W) lines on the PESCO or the template customer code, by page. HOLDS = { "WP": ("3.4",), "C": ("4.2", "4.4", "4.5", "5.1", "5.2", "5.3", "5.5", "6.1", "6.2", "6.3", "6.6"), "FA": ("7.5", "7.9", "7.11", "9.6", "9.7"), } ALL_HOLDS = HOLDS["WP"] + HOLDS["C"] + HOLDS["FA"] BLOCKS = { "3": ["1", "2", "3"], "4": ["4"], "5": ["5"], "6": ["6"], "7": ["7"], "8": ["8"], "9": ["9"], } DEPT_PAGES = { "01": ["WP"], "02": ["WP"], "03": ["WP"], "04": ["WP"], "05": ["WP"], "10": ["WP"], "06": ["C"], "07": ["C"], "13": ["C"], "09": ["WP", "C"], "08": ["FA"], "11": ["FA"], "12": ["FA"], "14": ["FA"], } # Two extra people for the "another Q.C. user" and Q.C. supervisor cases. FakeDirectory takes a user list, so a # test module adds them through its own fake_dir fixture; fakedir.py stays as it is. QC_SUE = FakeUser("qc.sue", "Sue Second", "5002", ["PS-QC"]) QC_SAM = FakeUser("qcsup.sam", "Sam Qcsup", "5003", ["PS-QC-Supervisor"]) QC_USERS = (*USERS, "qc.sue", "qcsup.sam") def qc_directory() -> FakeDirectory: d = FakeDirectory() d.users.extend( [ FakeUser(QC_SUE.sam, QC_SUE.display, QC_SUE.emp_no, list(QC_SUE.groups)), FakeUser(QC_SAM.sam, QC_SAM.display, QC_SAM.emp_no, list(QC_SAM.groups)), ] ) return d def open_qc_shop(settings: Settings, fake_dir: FakeDirectory) -> Iterator[Shop]: """open_shop, plus the two extra Q.C. people signed in once when the directory has them.""" for shop in open_shop(settings, fake_dir): for sam in ("qc.sue", "qcsup.sam"): if any(u.sam == sam for u in fake_dir.users): shop(sam) yield shop def today_us() -> str: """M/D/YYYY in America/Denver, the date form of the ITP notes.""" d = datetime.now(ZoneInfo("America/Denver")) return f"{d.month}/{d.day}/{d.year}" def today_iso() -> str: return datetime.now(ZoneInfo("America/Denver")).date().isoformat() def ok_any(r: Any) -> dict[str, Any]: """200 or 201 (the contract does not fix the create code).""" assert r.status_code in (200, 201), r.text out: dict[str, Any] = r.json() return out def g(obj: Any, key: str) -> Any: """A field of a dict or an object (the pure helpers may return either).""" return obj[key] if isinstance(obj, dict) else getattr(obj, key) def delete_json(c: TestClient, url: str, body: dict[str, Any] | None = None, **kw: Any) -> Any: return c.request("DELETE", url, json=body, **kw) # ---------------------------------------------------------------- the packet def to_qc(shop: Shop, pkt: dict[str, Any], location: str = "Bay 2", by: str = "sup.wes", **kw: Any) -> dict[str, Any]: out = ok(step(shop(by), pkt, "send_to_qc", book=True, spool=True, location=location, sign=SIGN, **kw)) assert out["status"] == "qc", out["status"] return out def at_qc(shop: Shop, unit_id: int | None = None, welds: int = 2, **kw: Any) -> dict[str, Any]: """A shop packet with `welds` welds, submitted, and sent to Q.C. at Bay 2 by sup.wes.""" pkt = in_progress(shop, unit_id, **kw) pkt = finish_stage(shop, pkt, welds=welds) pkt = submit(shop, pkt) return to_qc(shop, pkt) def live_welds(pkt: dict[str, Any]) -> list[int]: return sorted(int(w["weld_no"]) for w in pkt["welds"] if not w.get("removed_at") and not w.get("removed")) def qc_check(c: TestClient, pid: int, key: str, done: bool = True) -> Any: return c.put(f"/api/packets/{pid}/qc/checks/{key}", json={"done": done}) def qc_weld(c: TestClient, pid: int, no: int, **body: Any) -> Any: return c.put(f"/api/packets/{pid}/qc/welds/{no}", json=body) def qc_patch(c: TestClient, pid: int, set_: dict[str, Any], base: dict[str, Any] | None = None, **kw: Any) -> Any: return c.patch(f"/api/packets/{pid}/qc", json={"set": set_, "base": base or {}, **kw}) def finish_inspection( shop: Shop, pkt: dict[str, Any], by: str = "qc.quinn", inspector: str = "Quinn QC", stamp: str = "Q1", nde: tuple[str, str] | None = None, ) -> dict[str, Any]: """Every check ticked, every live weld accepted, the inspector and stamp set (and the NDE when given).""" c = shop(by) pid = pkt["id"] fields: dict[str, Any] = {"inspector": inspector, "stamp": stamp} pkt = ok(qc_patch(c, pid, fields)) for k in QC_KEYS: pkt = ok(qc_check(c, pid, k)) for no in live_welds(pkt): pkt = ok(qc_weld(c, pid, no, result="accept")) if nde is not None: pkt = ok(qc_patch(c, pid, {"nde_method": nde[0]})) pkt = ok(qc_patch(c, pid, {"nde_result": nde[1]})) assert pkt["qc"]["missing"] == [], pkt["qc"]["missing"] return pkt def accept(shop: Shop, pkt: dict[str, Any], routed_to: str = "databook", by: str = "qc.quinn") -> Any: return step(shop(by), pkt, "qc_accept", routed_to=routed_to, sign=SIGN) def accept_route(shop: Shop, pkt: dict[str, Any], dept: str = "09", by: str = "qc.quinn") -> Any: return step(shop(by), pkt, "qc_accept_and_route", dept=dept, sign=SIGN) def reject( shop: Shop, pkt: dict[str, Any], reason: str | None = None, ncr: str | None = None, by: str = "qc.quinn" ) -> Any: body: dict[str, Any] = {"sign": SIGN} if reason is not None: body["reason"] = reason if ncr is not None: body["ncr"] = ncr return step(shop(by), pkt, "qc_reject", **body) def accepted_packet(shop: Shop, unit_id: int | None = None, routed_to: str = "databook") -> dict[str, Any]: pkt = finish_inspection(shop, at_qc(shop, unit_id)) out = ok(accept(shop, pkt, routed_to)) assert out["status"] == "complete" return out def filed_packet(shop: Shop, unit_id: int | None = None) -> dict[str, Any]: """Accepted to the data book, closed in VISUAL and filed by clerk.clara.""" pkt = accepted_packet(shop, unit_id) c = shop("clerk.clara") pkt = ok(c.post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True})) out = ok(step(c, pkt, "file", sign=SIGN)) assert out["status"] == "filed" return out # ---------------------------------------------------------------- assets and documents def unique_pdf() -> bytes: """A one-page PDF with its own bytes. The asset store returns the existing row for the same bytes (and its original name), so each upload that names a file needs bytes of its own.""" import io import secrets from pypdf import PdfWriter w = PdfWriter() w.add_blank_page(width=612, height=792) w.add_metadata({"/Title": secrets.token_hex(8)}) buf = io.BytesIO() w.write(buf) return buf.getvalue() def upload_doc( c: TestClient, name: str = "doc.pdf", data: bytes | None = None, mime: str = "application/pdf", purpose: str = "document", ) -> dict[str, Any]: r = c.post( "/api/assets", files={"file": (name, unique_pdf() if data is None else data, mime)}, data={"purpose": purpose} ) assert r.status_code == 201, r.text out: dict[str, Any] = r.json() return out def csv_asset(c: TestClient) -> dict[str, Any]: """A stored CSV (through the BOM purpose), to try as a document.""" return upload_doc(c, "parts.csv", b"item,qty,part\n1,2,PIPE-4\n2,1,FLG-4\n", "text/csv", purpose="bom") def add_packet_doc( c: TestClient, pid: int, kind: str = "nde", title: str | None = "RT report W1-W4", note: str | None = None, file_name: str = "rt.pdf", asset_id: str | None = None, **kw: Any, ) -> Any: if asset_id is None: asset_id = upload_doc(c, file_name)["id"] body: dict[str, Any] = {"asset_id": asset_id, "kind": kind, **kw} if title is not None: body["title"] = title if note is not None: body["note"] = note return c.post(f"/api/packets/{pid}/docs", json=body) def take_out(c: TestClient, pid: int, doc_id: int, reason: str | None = "wrong packet") -> Any: return delete_json(c, f"/api/packets/{pid}/docs/{doc_id}", {} if reason is None else {"reason": reason}) def make_qc_doc( shop: Shop, *, unit_id: int | None = None, project_id: int | None = None, type: str = "ITP", title: str | None = None, rev: str | None = None, note: str | None = None, replaces: int | None = None, by: str = "qc.quinn", file_name: str = "doc.pdf", asset_id: str | None = None, **kw: Any, ) -> Any: """POST /api/qc/docs with a fresh tiny PDF (purpose document). Returns the response.""" c = shop(by) if asset_id is None: asset_id = upload_doc(c, file_name)["id"] body: dict[str, Any] = {"type": type, "asset_id": asset_id, **kw} for k, v in ( ("unit_id", unit_id), ("project_id", project_id), ("title", title), ("rev", rev), ("note", note), ("replaces", replaces), ): if v is not None: body[k] = v return c.post("/api/qc/docs", json=body) def folder_unit(c: TestClient, unit_id: int) -> dict[str, Any]: return ok(c.get(f"/api/qc/folders/units/{unit_id}")) def folder_project(c: TestClient, project_id: int) -> dict[str, Any]: return ok(c.get(f"/api/qc/folders/projects/{project_id}")) # ---------------------------------------------------------------- the ITP def get_itp(c: TestClient, unit_id: int) -> dict[str, Any]: return ok(c.get(f"/api/units/{unit_id}/itp")) def start_itp(shop: Shop, unit_id: int, by: str = "qc.quinn") -> dict[str, Any]: itp = ok_any(shop(by).post(f"/api/units/{unit_id}/itp", json={})) assert itp["status"] == "open", itp return itp def item_keys(itp: dict[str, Any]) -> list[str]: return [str(i["n"]) for s in itp["template"]["sections"] for i in s["items"]] def section_keys(itp: dict[str, Any], n: str) -> list[str]: sec = next(s for s in itp["template"]["sections"] if str(s["n"]) == n) return [str(i["n"]) for i in sec["items"]] def line(itp: dict[str, Any], key: str) -> dict[str, Any]: out: dict[str, Any] = itp["lines"][key] return out def block(itp: dict[str, Any], key: str) -> dict[str, Any]: return next(b for b in itp["blocks"] if str(b["key"]) == key) def is_done(ln: dict[str, Any]) -> bool: return bool(ln.get("na")) or ln.get("result") in ("approve", "na") def sign_line(c: TestClient, itp: dict[str, Any], key: str, if_match: int | None = None, **body: Any) -> Any: headers = {} if if_match is None else {"If-Match": str(if_match)} return c.post(f"/api/itps/{itp['id']}/lines/{key}/sign", json=body, headers=headers) def unsign_line(c: TestClient, itp: dict[str, Any], key: str, **body: Any) -> Any: return c.post(f"/api/itps/{itp['id']}/lines/{key}/unsign", json=body) def patch_line(c: TestClient, itp: dict[str, Any], key: str, **body: Any) -> Any: return c.patch(f"/api/itps/{itp['id']}/lines/{key}", json=body) def section_na(c: TestClient, itp: dict[str, Any], n: str, na: bool = True) -> Any: return c.post(f"/api/itps/{itp['id']}/sections/{n}/na", json={"na": na}) def reject_line(c: TestClient, itp: dict[str, Any], key: str, **body: Any) -> Any: return c.post(f"/api/itps/{itp['id']}/lines/{key}/reject", json=body) def enter_ncr(c: TestClient, itp: dict[str, Any], key: str, ncr: str) -> Any: return c.post(f"/api/itps/{itp['id']}/lines/{key}/ncr", json={"ncr": ncr}) def patch_block(c: TestClient, itp: dict[str, Any], key: str, **body: Any) -> Any: return c.patch(f"/api/itps/{itp['id']}/blocks/{key}", json=body) def sign_block(c: TestClient, itp: dict[str, Any], key: str, sign: dict[str, Any] | None = SIGN, **body: Any) -> Any: if sign is not None: body["sign"] = sign return c.post(f"/api/itps/{itp['id']}/blocks/{key}/sign", json=body) def unsign_block(c: TestClient, itp: dict[str, Any], key: str, **body: Any) -> Any: return delete_json(c, f"/api/itps/{itp['id']}/blocks/{key}/sign", body) def sign_off(c: TestClient, itp: dict[str, Any], sign: dict[str, Any] | None = SIGN, **body: Any) -> Any: if sign is not None: body["sign"] = sign return c.post(f"/api/itps/{itp['id']}/signoff", json=body) def reopen_itp(c: TestClient, itp: dict[str, Any], reason: str, **body: Any) -> Any: return c.post(f"/api/itps/{itp['id']}/reopen", json={"reason": reason, **body}) def patch_itp(c: TestClient, itp: dict[str, Any], set_: dict[str, Any], base: dict[str, Any] | None = None) -> Any: return c.patch(f"/api/itps/{itp['id']}", json={"set": set_, "base": base or {}}) def approve_all(shop: Shop, itp: dict[str, Any], by: str = "qc.quinn", keys: list[str] | None = None) -> dict[str, Any]: """Approve every line that is still open (not N/A, not approved). A rejected line needs its NCR first.""" c = shop(by) for key in keys if keys is not None else item_keys(itp): ln = line(itp, key) if is_done(ln): continue itp = ok(sign_line(c, itp, key)) return itp def na_lines(shop: Shop, itp: dict[str, Any], keys: list[str], by: str = "qc.quinn") -> dict[str, Any]: c = shop(by) for key in keys: itp = ok(patch_line(c, itp, key, na=True)) return itp def sign_all_blocks(shop: Shop, itp: dict[str, Any], by: str = "qc.quinn") -> dict[str, Any]: c = shop(by) for b in list(itp["blocks"]): if not block(itp, str(b["key"]))["signed"]: itp = ok(sign_block(c, itp, str(b["key"]))) return itp def itp_log(itp: dict[str, Any]) -> list[tuple[str, str | None]]: """(action, note) newest first.""" return [(r["action"], r["note"]) for r in itp["log"]] def patch_hook(monkeypatch: pytest.MonkeyPatch, fn: Any) -> None: """Replace pdf.itp.file_signed_itp_pdf, also where a module imported it by name.""" for name in ("packetstation.pdf.itp", "packetstation.api.itp", "packetstation.domain.itp"): try: mod = importlib.import_module(name) except ImportError: continue if hasattr(mod, "file_signed_itp_pdf"): monkeypatch.setattr(mod, "file_signed_itp_pdf", fn) def as_uuid(value: Any) -> uuid.UUID: return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))