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]>
This commit is contained in:
@@ -0,0 +1,504 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user