"""The digital ITP (Inspection and Test Plan), one per unit. Starting an ITP freezes a copy of the Q.C. template (domain/itp_template.py) into itps.template, so a later template change never alters an ITP in use. Q.C. initials each line (approve), marks lines N/A, rejects a line with an NCR number (now or later), records the customer's code and witness, signs each block of sections with a signature, and signs the ITP off with a signature. Open hold (H) and witness (W) points, and rejected lines, show as a warning on the unit's packets for the department's pages. Nothing is blocked by them. The pure functions here (items, is_done, is_hold_code, open_holds, blocks, block_ready, progress, dept_pages, holds_for_packet) port the old itp.js helpers. They take the frozen template and the lines as a mapping of line key to a row with `result` ("approve", "reject", "na" or None), `cust_code` and `ncr`. Writes go through `itp_write`: check_scoped with the action, then the ITP row locked FOR UPDATE, the complete lock, the signature when the rule needs one, then the version bump, the itp_log rows, the audit rows and the NOTIFY. ITP writes never look at a packet's status: a filed packet in the unit does not lock the unit's ITP. """ from __future__ import annotations import re from collections.abc import Iterable, Iterator, Mapping, Sequence from contextlib import contextmanager from dataclasses import dataclass, field from datetime import date, datetime from typing import Any from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from .. import audit, events, policy from ..api import deps from ..api.errors import ApiError from ..auth import signatures from ..auth.signatures import Signature from ..policy.actions import SCOPED_LABELS from ..policy.engine import check_scoped from ..text import LIMITS, clean_opt, reason_ok from . import packets A = policy.Action PAGE_NAMES: dict[str, str] = {"WP": "Weld Parts", "C": "Code", "FA": "Final Assembly"} PAGE_ORDER: tuple[str, ...] = ("WP", "C", "FA") # The pages each shop department works to (the old ITP_DEPT_PAGES). A support department or an unknown code sees all. DEPT_PAGES: dict[str, tuple[str, ...]] = { "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",), } CUST_CODES: tuple[str, ...] = ("R", "R/AU", "AU", "W", "H", "NA") RX_HOLD = re.compile(r"(^|/)(H|W)$") LOG_MAX = 600 MSG_COMPLETE = "The ITP is signed off and locked. An admin can reopen it." MSG_NO_ITP = "No such ITP." # ---------------------------------------------------------------- pure functions over the template and lines def items(template: Mapping[str, Any]) -> list[dict[str, Any]]: """Every line of the template in order, with its section number (`sec`) and page.""" out: list[dict[str, Any]] = [] for s in template.get("sections") or []: for i in s.get("items") or []: out.append({**i, "sec": s.get("n"), "page": s.get("page")}) return out def _line(lines: Mapping[str, Any], key: str) -> Mapping[str, Any]: v = lines.get(key) return v if isinstance(v, Mapping) else {} def is_done(line: Mapping[str, Any] | None) -> bool: """A line is done when it is N/A or approved.""" return bool(line) and (line or {}).get("result") in ("approve", "na") def is_rejected(line: Mapping[str, Any] | None) -> bool: """An open reject: the line is rejected and not approved since.""" return bool(line) and (line or {}).get("result") == "reject" def is_hold_code(code: Any) -> bool: """H or W, alone or after a slash (R/H). The old isHoldCode.""" return bool(RX_HOLD.search(str(code or ""))) def cust_code(item: Mapping[str, Any], line: Mapping[str, Any] | None) -> str: """The customer code of a line: its own when set, else the template's.""" own = (line or {}).get("cust_code") return str(own) if own else str(item.get("cust") or "") def hold_codes(item: Mapping[str, Any], line: Mapping[str, Any] | None) -> list[str]: return [c for c in (str(item.get("code") or ""), cust_code(item, line)) if is_hold_code(c)] def open_holds( template: Mapping[str, Any], lines: Mapping[str, Any], pages: Sequence[str] | None = None ) -> list[dict[str, Any]]: """The hold, witness or rejected points not done yet, on the given pages (None: every page).""" out: list[dict[str, Any]] = [] for i in items(template): if pages is not None and i["page"] not in pages: continue line = _line(lines, str(i["n"])) codes = hold_codes(i, line) rej = is_rejected(line) if (codes or rej) and not is_done(line): out.append( { "n": i["n"], "t": i["t"], "page": i["page"], "codes": codes, "rejected": rej, "ncr": line.get("ncr") if rej else None, } ) return out def blocks(template: Mapping[str, Any]) -> list[dict[str, Any]]: """The signature blocks: one closes at every section with `sig` and covers the sections since the last block.""" out: list[dict[str, Any]] = [] acc: list[Mapping[str, Any]] = [] for s in template.get("sections") or []: acc.append(s) if s.get("sig"): out.append({"key": str(s["n"]), "sections": acc, "final": bool(s.get("final"))}) acc = [] if acc: out.append({"key": str(acc[-1]["n"]), "sections": acc, "final": True}) return out def block_scope(block: Mapping[str, Any]) -> str: secs = block["sections"] if len(secs) > 1: return f"Sections {secs[0]['n']}–{secs[-1]['n']}" return f"Section {block['key']}" def block_of_line(template: Mapping[str, Any], key: str) -> dict[str, Any] | None: for b in blocks(template): if any(str(i.get("n")) == key for s in b["sections"] for i in s.get("items") or []): return b return None def _block_lines(block: Mapping[str, Any]) -> list[str]: return [str(i.get("n")) for s in block["sections"] for i in s.get("items") or []] def block_ready(block: Mapping[str, Any], lines: Mapping[str, Any]) -> bool: """Every line of the block is done (approved or N/A).""" return all(is_done(_line(lines, k)) for k in _block_lines(block)) def block_all_na(block: Mapping[str, Any], lines: Mapping[str, Any]) -> bool: return all(_line(lines, k).get("result") == "na" for k in _block_lines(block)) def progress(template: Mapping[str, Any], lines: Mapping[str, Any]) -> dict[str, int]: it = items(template) return { "done": sum(1 for i in it if is_done(_line(lines, str(i["n"])))), "total": len(it), "holds": len(open_holds(template, lines)), } def dept_pages(code: Any) -> list[str] | None: """The pages a department works to, or None for a support department or an unknown code (every page).""" pages = DEPT_PAGES.get(str(code or "")) return list(pages) if pages is not None else None def holds_for_packet( itp_row: Mapping[str, Any] | None, lines: Mapping[str, Any], dept_code: Any ) -> dict[str, Any] | None: """The packet's ITP warning: open hold, witness or rejected points on its department's pages, while the unit's ITP is open. None when there is none.""" if itp_row is None or str(itp_row.get("status")) == "complete": return None pages = dept_pages(dept_code) hs = open_holds(itp_row["template"] or {}, lines, pages) if not hs: return None return { "no": itp_row.get("no"), "count": len(hs), "rejected": any(h["rejected"] for h in hs), "pages": pages, "items": [{k: h[k] for k in ("n", "t", "codes", "rejected", "ncr")} for h in hs], } def initials(name: Any) -> str: """The first letter of each word of a name, upper case, at most three. The old initials().""" return "".join(w[0].upper() for w in str(name or "").split() if w)[:3] def site_zone(tz: str | None) -> ZoneInfo: try: return ZoneInfo(tz or "America/Denver") except (ZoneInfoNotFoundError, ValueError): return ZoneInfo("America/Denver") def date_text(value: Any, tz: str | None = None) -> str: """M/D/YYYY in the site time zone (10/17/2026).""" if value is None: return "" if isinstance(value, datetime): d = value.astimezone(site_zone(tz)).date() if value.tzinfo else value.date() elif isinstance(value, date): d = value else: return str(value) return f"{d.month}/{d.day}/{d.year}" def today(tz: str | None = None) -> date: return datetime.now(site_zone(tz)).date() # ---------------------------------------------------------------- loading def load_row(conn: Any, itp_id: int, *, lock: bool = False) -> dict[str, Any] | None: sql = "SELECT *, status::text AS status FROM itps WHERE id = %s" if lock: sql += " FOR UPDATE" return packets.one(conn, sql, (itp_id,)) def row_for_unit(conn: Any, unit_id: int) -> dict[str, Any] | None: return packets.one(conn, "SELECT *, status::text AS status FROM itps WHERE unit_id = %s", (unit_id,)) def load_lines(conn: Any, itp_id: int) -> dict[str, dict[str, Any]]: return {r["key"]: r for r in packets.rows(conn, "SELECT * FROM itp_lines WHERE itp_id = %s", (itp_id,))} def load_blocks(conn: Any, itp_id: int) -> dict[str, dict[str, Any]]: return {r["block_key"]: r for r in packets.rows(conn, "SELECT * FROM itp_blocks WHERE itp_id = %s", (itp_id,))} def summary(itp_row: Mapping[str, Any], lines: Mapping[str, Any]) -> dict[str, Any]: pr = progress(itp_row["template"] or {}, lines) return { "id": itp_row["id"], "no": itp_row["no"], "status": str(itp_row["status"]), "done": pr["done"], "total": pr["total"], "holds": pr["holds"], } def unit_itps(conn: Any, unit_ids: Iterable[int]) -> dict[int, tuple[dict[str, Any], dict[str, dict[str, Any]]]]: """The ITP row and its lines per unit, for the given units, in two queries.""" ids = sorted({int(u) for u in unit_ids}) if not ids: return {} itps = packets.rows(conn, "SELECT *, status::text AS status FROM itps WHERE unit_id = ANY(%s)", (ids,)) if not itps: return {} lines: dict[int, dict[str, dict[str, Any]]] = {r["id"]: {} for r in itps} for ln in packets.rows( conn, "SELECT itp_id, key, result, cust_code, ncr FROM itp_lines WHERE itp_id = ANY(%s)", ([r["id"] for r in itps],), ): lines[ln["itp_id"]][ln["key"]] = ln return {int(r["unit_id"]): (r, lines[r["id"]]) for r in itps} def summaries(conn: Any, unit_ids: Iterable[int]) -> dict[int, dict[str, Any]]: return {u: summary(row, lines) for u, (row, lines) in unit_itps(conn, unit_ids).items()} # ---------------------------------------------------------------- the read def can_of(pp: policy.Principal, itp_row: Mapping[str, Any], blocks_signed: bool) -> dict[str, bool]: open_ = str(itp_row["status"]) != "complete" edit = open_ and check_scoped(pp, A.ITP_EDIT_LINE, assume_it_reason=True).ok sign_off = open_ and blocks_signed and check_scoped(pp, A.ITP_SIGN_OFF, assume_it_reason=True).ok reopen = (not open_) and check_scoped(pp, A.ITP_REOPEN, extra={"reason": "x" * 8}, assume_it_reason=True).ok return {"edit": edit, "sign_off": sign_off, "reopen": reopen} def read(conn: Any, itp_row: Mapping[str, Any], pp: policy.Principal) -> dict[str, Any]: """The full ITP answer (GET /api/units/{unit_id}/itp and every ITP write).""" iid = itp_row["id"] template = itp_row["template"] or {} lines = load_lines(conn, iid) brows = load_blocks(conn, iid) rejects: dict[str, list[dict[str, Any]]] = {} for r in packets.rows(conn, "SELECT * FROM itp_rejects WHERE itp_id = %s ORDER BY id", (iid,)): rejects.setdefault(r["line_key"], []).append(r) log_rows = packets.rows(conn, "SELECT * FROM itp_log WHERE itp_id = %s ORDER BY id DESC LIMIT %s", (iid, LOG_MAX)) ids: list[Any] = [itp_row["signoff_by"], itp_row["reopen_by"], itp_row["created_by"]] for ln in lines.values(): ids += [ln["by"], ln["cust_rec"]] for b in brows.values(): ids += [b["signed_by"], b["cust_by"]] for rs in rejects.values(): ids += [r["by"] for r in rs] ids += [r["by"] for r in log_rows] people = packets.People(conn, ids) unit = packets.one( conn, "SELECT u.id, u.number, u.project_id, u.project_number FROM v_units u WHERE u.id = %s", (itp_row["unit_id"],), ) out_lines: dict[str, Any] = {} for i in items(template): key = str(i["n"]) ln = lines.get(key) or {} result = ln.get("result") out_lines[key] = { "key": key, "result": result, "na": result == "na", "by": people.ref(ln.get("by")) if result == "approve" else None, "initials": ln.get("initials") if result == "approve" else None, "at": ln.get("at") if result == "approve" else None, "ncr": ln.get("ncr"), "ncr_missing": result == "reject" and not ln.get("ncr"), "note": ln.get("note"), "cust_code": ln.get("cust_code"), "cust_by": ln.get("cust_by"), "cust_at": ln.get("cust_at"), "cust_rec": people.ref(ln.get("cust_rec")), "rejects": [ {"at": r["at"], "by": people.ref(r["by"]), "ncr": r["ncr"], "note": r["note"], "kind": r["kind"]} for r in rejects.get(key, []) ], "row_version": ln.get("row_version", 0), } out_blocks: list[dict[str, Any]] = [] for b in blocks(template): br = brows.get(b["key"]) or {} out_blocks.append( { "key": b["key"], "sections": [str(s["n"]) for s in b["sections"]], "scope": block_scope(b), "final": b["final"], "ready": block_ready(b, lines), "all_na": block_all_na(b, lines), "signed": br.get("signed_at") is not None, "signed_at": br.get("signed_at"), "signed_by": people.ref(br.get("signed_by")) if br.get("signed_at") is not None else None, "qn": br.get("qn"), "remarks": br.get("remarks"), "cust_name": br.get("cust_name"), "cust_at": br.get("cust_at"), "cust_by": people.ref(br.get("cust_by")), "row_version": br.get("row_version", 0), } ) all_signed = bool(out_blocks) and all(b["signed"] for b in out_blocks) pages_present = {s.get("page") for s in template.get("sections") or []} status = str(itp_row["status"]) return { "id": iid, "unit_id": itp_row["unit_id"], "unit": None if unit is None else { "id": unit["id"], "number": unit["number"], "project": {"id": unit["project_id"], "number": unit["project_number"]}, }, "no": itp_row["no"], "hdr": {k: (itp_row["hdr"] or {}).get(k) for k in HDR_KEYS}, "status": "complete" if status == "complete" else "open", "version": itp_row["version"], "template": template, "pages": [{"code": c, "name": PAGE_NAMES[c]} for c in PAGE_ORDER if c in pages_present], "lines": out_lines, "blocks": out_blocks, "progress": progress(template, lines), "holds": open_holds(template, lines), "signoff": None if itp_row["signoff_at"] is None else { "at": itp_row["signoff_at"], "by": people.ref(itp_row["signoff_by"]), "asset_id": packets.uid_text(itp_row["signoff_asset_id"]), }, "reopen": None if itp_row["reopen_at"] is None else {"at": itp_row["reopen_at"], "by": people.ref(itp_row["reopen_by"]), "reason": itp_row["reopen_reason"]}, "created_at": itp_row["created_at"], "created_by": people.ref(itp_row["created_by"]), "can": can_of(pp, itp_row, all_signed), "log": [ { "id": r["id"], "at": r["at"], "by": people.ref(r["by"]), "by_name": r["by_name"], "action": r["action"], "note": r["note"], "signed": r["signature_id"] is not None, } for r in log_rows ], } HDR_KEYS: tuple[str, ...] = ("client", "pname", "pno", "po", "unit") # ---------------------------------------------------------------- the write @dataclass class ItpWrite: conn: Any principal: deps.Principal policy_principal: policy.Principal itp: dict[str, Any] action: policy.Action decision: policy.Decision request: Any it_reason: str | None # the cleaned IT reason, kept only when the admin bypass was used signature: Signature | None = None logs: list[tuple[str, str | None]] = field(default_factory=list) version: int = 0 @property def id(self) -> int: return int(self.itp["id"]) @property def user_id(self) -> Any: return self.principal.user_id @property def tz(self) -> str: return str(deps.settings_of(self.request).site_tz) def log(self, action: str, note: str | None = None) -> None: """One itp_log row (and audit row) for this write. A write that logs nothing changes nothing.""" self.logs.append((action, note)) def refuse(decision: policy.Decision) -> None: if decision.ok: return status = 400 if decision.code in ("it_reason_required", "reason_required") else 403 raise ApiError(status, decision.code, decision.why) def scoped_decision( p: deps.Principal, pp: policy.Principal, action: policy.Action, *, extra: Mapping[str, Any] | None = None, it_reason: str | None = None, ) -> tuple[policy.Decision, str | None]: """check_scoped, raising the refusal. Returns the decision and the IT reason to record (bypass only).""" del p decision = check_scoped(pp, action, extra=extra, it_reason=it_reason) refuse(decision) return decision, (reason_ok(it_reason) if decision.admin_bypass else None) @contextmanager def itp_write( d: deps.Db, p: deps.Principal, itp_id: int, action: policy.Action, *, request: Any, extra: Mapping[str, Any] | None = None, it_reason: str | None = None, sign: Any = None, allow_complete: bool = False, sign_extra: Mapping[str, Any] | None = None, ) -> Iterator[ItpWrite]: """One ITP write, in one transaction. See the module docstring for the order of steps. A wrong signature secret commits the step-up failure records and then raises, like the packet pipeline. """ conn = d.conn pp = packets.policy_principal_for(conn, p) decision, it_kept = scoped_decision(p, pp, action, extra=extra, it_reason=it_reason) failure: ApiError | None = None with conn.transaction(): row = load_row(conn, itp_id, lock=True) if row is None: raise ApiError(404, "not_found", MSG_NO_ITP) if str(row["status"]) == "complete" and not allow_complete: raise ApiError(409, "itp_complete", MSG_COMPLETE) w = ItpWrite( conn=conn, principal=p, policy_principal=pp, itp=row, action=action, decision=decision, request=request, it_reason=it_kept, version=int(row["version"]), ) if decision.signature: if sign is None: raise ApiError( 400, "signature_required", "This step needs your signature.", extra={"meaning": decision.signature} ) try: w.signature = signatures.sign( conn, deps.directory_of(request), p.actor, factor=sign.factor, secret=sign.secret, action=f"itp.{action}", meaning=decision.signature, packet_id=None, subject_type="itp", subject_id=itp_id, content={ "action": str(action), "itp_id": itp_id, "version": int(row["version"]), **(sign_extra or {}), }, ip=p.ip, settings=deps.settings_of(request), ) except ApiError as e: failure = e if failure is None: yield w _finish(w) if failure is not None: raise failure def _finish(w: ItpWrite) -> None: if not w.logs: return conn = w.conn w.version = int( conn.execute("UPDATE itps SET version = version + 1 WHERE id = %s RETURNING version", (w.id,)).fetchone()[0] ) sig_id = w.signature.id if w.signature else None name = w.principal.user["display_name"] for n, (action, note) in enumerate(w.logs): text = clean_opt(note, LIMITS["note"]) if note else None sid = sig_id if n == 0 else None conn.execute( "INSERT INTO itp_log (itp_id, by, by_name, action, note, signature_id, it_reason)" " VALUES (%s, %s, %s, %s, %s, %s, %s)", (w.id, w.user_id, name, action, text, sid, w.it_reason), ) detail: dict[str, Any] = {"text": action, "note": text, "unit_id": w.itp["unit_id"]} if sid: detail["signature_id"] = sid audit.append( conn, action=f"itp.{w.action}", user_id=w.user_id, session_id=w.principal.session_id, role_snapshot=w.principal.role_snapshot, subject_type="itp", subject_id=w.id, detail=detail, it_reason=w.it_reason, ip=w.principal.ip, ) events.publish(conn, {"event": "changed", "kind": "itp", "unit_id": w.itp["unit_id"], "itp_id": w.id}) def label(action: policy.Action) -> str: return SCOPED_LABELS.get(action, str(action))