Phase 7: close, file, PDFs and prints
ci / frontend (push) Successful in 40s
ci / server (push) Successful in 10m56s
ci / image (push) Successful in 48s
ci / e2e (push) Successful in 2m44s

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]>
This commit is contained in:
2026-10-17 23:49:14 -06:00
co-authored by Claude Fable 5.1
parent 00531cb329
commit 904437153d
59 changed files with 8226 additions and 136 deletions
+112 -8
View File
@@ -14,12 +14,16 @@ Child-row endpoints build on the same pieces: `check_row_version`, `next_counter
from __future__ import annotations
import contextlib
import hashlib
import hmac
import logging
import os
import tempfile
from collections.abc import Iterator, Mapping, Sequence
from contextlib import contextmanager
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
from fastapi import Request
@@ -45,6 +49,16 @@ MSG_STALE = "Someone else already took this step."
MSG_VERSION = "The packet changed since you opened it. Reload and try again."
MSG_ROW = "Someone else changed this line. Reload and try again."
MSG_FIELD = "Someone else changed this since you opened it."
MSG_DATABOOK = "The data book could not be built: {reason}. Nothing was filed."
def open_wos_text(wos: Sequence[str]) -> str:
"""The FILE confirm question: "VISUAL still shows 2 work orders open on this packet: L489052/1, L489052/2. File
anyway?" """
n = len(wos)
what = "1 work order" if n == 1 else f"{n} work orders"
return f"VISUAL still shows {what} open on this packet: {', '.join(wos)}. File anyway?"
# Codes that answer 400 (the UI asks for input and retries); every other refusal answers 403.
ASK_CODES = frozenset({"it_reason_required", "reason_required"})
@@ -143,6 +157,17 @@ class Write:
def rule(self) -> policy.Rule:
return policy.RULES[self.action]
@property
def settings(self) -> Any:
return _settings(self)
@property
def actor_ref(self) -> Any:
"""The signed-in person as the PDF builders' WhoRef: id, display name and employee number."""
from . import prints
return prints.who_of(self.principal)
def log(self, action_label: str, note: str | None = None) -> None:
"""Override the packet_log label (and note) for this write."""
self.label = action_label
@@ -323,6 +348,11 @@ def packet_write(
bypass = bypass or dd.admin_bypass
if if_match is not None and int(if_match) != int(row["version"]):
raise ApiError(409, "version_conflict", MSG_VERSION, extra={"current": current_of(row)})
if action is A.FILE and extra.get("confirm_open_wos") is not True:
# Before the signature: a cancelled confirm costs no attempt and writes nothing.
wos = packets.packet_open_wos(conn, row)
if wos:
raise ApiError(409, "open_work_orders", open_wos_text(wos), extra={"work_orders": wos})
w = Write(
conn=conn,
principal=p,
@@ -770,14 +800,7 @@ def apply_transition(w: Write) -> None:
_set_packet(w, "status = %s::packet_status, status_at = now(), dept_code = %s", (to_status, to_dept))
if a is A.FILE:
key = _seal_key(w)
row = packets.load_row(w.conn, w.id)
assert row is not None
content = packets.seal_content(w.conn, row)
w.conn.execute(
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by, signature_id) VALUES (%s, %s, %s, %s, %s)",
(w.id, seal_hash(key, content), seal_key_id(key), w.user_id, w.signature.id if w.signature else None),
)
_file(w)
elif a is A.ADMIN_REOPEN:
w.conn.execute(
"UPDATE packet_seals SET broken_at = now(), broken_by = %s, broken_reason = %s"
@@ -807,6 +830,84 @@ def apply_transition(w: Write) -> None:
w.note = reason or _note(w)
def _file(w: Write) -> None:
"""FILE: the data book date, the seal, and the data book built and stored on the seal, all in this transaction.
A data book that cannot be built answers 500 databook_failed and the whole filing rolls back."""
key = _seal_key(w)
_set_packet(w, "databook_at = now()")
row = packets.load_row(w.conn, w.id)
assert row is not None
wos = packets.packet_open_wos(w.conn, row) if w.extra.get("confirm_open_wos") is True else []
content = packets.seal_content(w.conn, row)
digest = seal_hash(key, content)
kid = seal_key_id(key)
seal_id, sealed_at = w.conn.execute(
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by, signature_id, open_wos)"
" VALUES (%s, %s, %s, %s, %s, %s) RETURNING id, sealed_at",
(w.id, digest, kid, w.user_id, w.signature.id if w.signature else None, wos or None),
).fetchone()
pages = _file_databook(w, row, {"hash": digest.hex(), "key_id": kid, "sealed_at": sealed_at}, int(seal_id))
w.log_also("Data book filed", f"{pages} pages")
if wos:
w.note = " · ".join(x for x in (_note(w), "Filed with work orders open in VISUAL: " + ", ".join(wos)) if x)
w.detail["open_wos"] = wos
w.detail["seal_id"] = int(seal_id)
w.detail["databook_pages"] = pages
def _file_databook(w: Write, row: Mapping[str, Any], seal: dict[str, Any], seal_id: int) -> int:
"""Build the filed data book, store it as an asset and put it on the seal row. Returns its page count."""
from ..pdf import databook
from ..pdf.style import PdfError
from . import assets
from . import prints as prints_dom
settings = _settings(w)
try:
built = databook.build(w.conn, settings, w.id, who=w.actor_ref, seal=seal, mode="filed")
except PdfError as e:
log.warning("data book for packet %s failed: %s", w.id, e)
raise ApiError(500, "databook_failed", MSG_DATABOOK.format(reason=_reason_text(e))) from None
read = packets.read_packet(w.conn, w.id, None, include_children=False)
name = prints_dom.databook_name(read)
tmp_dir = Path(settings.assets_dir) / "tmp"
tmp_dir.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(prefix="databook-", suffix=".pdf", dir=tmp_dir)
with os.fdopen(fd, "wb") as f:
f.write(built.bytes)
try:
stored = assets.store_file(
w.conn,
settings,
Path(tmp_name),
mime=assets.PDF,
original_name=name,
source="databook",
source_ref={"packet_id": w.id, "seal_id": seal_id},
uploaded_by=w.user_id,
packet_id=w.id,
)
except ApiError as e:
log.warning("data book for packet %s was not stored: %s", w.id, e.detail)
raise ApiError(500, "databook_failed", MSG_DATABOOK.format(reason=e.detail.rstrip("."))) from None
finally:
with contextlib.suppress(OSError):
Path(tmp_name).unlink()
pages = int(built.pages)
w.conn.execute(
"UPDATE packet_seals SET databook_asset_id = %s, databook_pages = %s WHERE id = %s",
(stored["id"], pages, seal_id),
)
w.detail["databook_asset_id"] = str(stored["id"])
return pages
def _reason_text(e: BaseException) -> str:
text = " ".join(str(e).split()).rstrip(".")
return text[:300] or "an unknown error"
def verify_seal(conn: Any, settings: Any, packet_id: int) -> dict[str, Any]:
"""The seal history of a packet and whether the current unbroken seal still matches the packet."""
seals = packets.rows(conn, "SELECT * FROM packet_seals WHERE packet_id = %s ORDER BY id DESC", (packet_id,))
@@ -834,6 +935,9 @@ def verify_seal(conn: Any, settings: Any, packet_id: int) -> dict[str, Any]:
"broken_at": s["broken_at"],
"broken_by": people.ref(s["broken_by"]),
"broken_reason": s["broken_reason"],
"open_wos": None if s.get("open_wos") is None else list(s["open_wos"]),
"databook_asset_id": None if s.get("databook_asset_id") is None else str(s["databook_asset_id"]),
"databook_pages": s.get("databook_pages"),
}
return {"seal": shape(current) if current else None, "valid": valid, "seals": [shape(s) for s in seals]}