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
+182
View File
@@ -0,0 +1,182 @@
"""Printed copies and exports: who the PDF builders stamp, the file names, and the append-only print log.
Every print and every export writes one print_log row and one audit row, in its own transaction, after the bytes
were built. Prints and exports are not packet changes: they write no packet_log row and do not bump the version.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, Literal, cast
from urllib.parse import quote
from .. import audit
from ..api import deps
from . import assets
from . import packets as packets_dom
if TYPE_CHECKING:
from ..pdf.style import WhoRef
PRINTS_LIMIT = 200
# The print_log kinds of the exports (prints use the print kind itself), and the audit kind of each.
EXPORT_KINDS = {"databook-export": "databook", "traveler": "traveler", "csv": "csv", "itp-pdf": "itp"}
def who_of(p: deps.Principal) -> WhoRef:
"""The signed-in person as the builders' WhoRef: {"id", "display_name", "emp_no"}."""
emp = p.user.get("emp_no")
return cast(
"WhoRef",
{
"id": str(p.user_id),
"display_name": str(p.user.get("display_name") or p.user.get("sam_account") or ""),
"emp_no": None if emp is None or str(emp).strip() == "" else str(emp),
},
)
def who_of_user(conn: Any, user_id: Any) -> WhoRef:
"""The same WhoRef for a user id (the ITP hook gets an actor, not a principal)."""
r = conn.execute("SELECT display_name, emp_no FROM users WHERE id = %s", (user_id,)).fetchone()
name = str(r[0] or "") if r else ""
emp = r[1] if r else None
return cast(
"WhoRef",
{"id": str(user_id), "display_name": name, "emp_no": None if emp is None or str(emp) == "" else str(emp)},
)
# ---------------------------------------------------------------- file names
def job_base(read: Mapping[str, Any]) -> str:
"""{project}-Lot{lot}: the top project number and the unit's lot, as the old build named its files."""
unit = read.get("unit") or {}
project = (unit.get("project") or {}).get("number") or ""
lot = unit.get("lot") or unit.get("number") or ""
return f"{project}-Lot{lot}"
def _named(text: str, fallback: str) -> str:
return assets.safe_name(text) or fallback
def databook_name(read: Mapping[str, Any]) -> str:
return _named(f"{job_base(read)}_{read.get('drawing') or ''}_Manufacturing_Data_Book.pdf", "Data_Book.pdf")
def traveler_name(read: Mapping[str, Any]) -> str:
return _named(f"{job_base(read)}_Job_Traveler.pdf", "Job_Traveler.pdf")
def print_name(read: Mapping[str, Any], what: str) -> str:
return _named(f"{job_base(read)} {what}.pdf", "Print.pdf")
def csv_name(read: Mapping[str, Any]) -> str:
return _named(f"{job_base(read)}_{read.get('drawing') or ''}_traceability.csv", "traceability.csv")
def itp_name(no: Any) -> str:
return _named(f"{no or 'ITP'}.pdf", "ITP.pdf")
def disposition(filename: str, download: bool = False) -> str:
"""Content-Disposition: inline unless a download is asked for. An ASCII filename, plus filename* (RFC 5987) when
the name has characters outside ASCII."""
kind = "attachment" if download else "inline"
plain = filename.encode("ascii", "replace").decode("ascii").replace("?", "_")
plain = plain.replace('"', "").replace(";", "_").replace("\\", "_").strip() or "download"
out = f'{kind}; filename="{plain}"'
if plain != filename:
out += f"; filename*=UTF-8''{quote(filename, safe='')}"
return out
# ---------------------------------------------------------------- the log
def record(
conn: Any,
p: deps.Principal,
*,
action: Literal["print", "export"],
kind: str,
what: str,
pages: int | None,
packet_id: int | None = None,
unit_id: int | None = None,
itp_id: int | None = None,
asset_id: Any = None,
stamp: str | None = None,
stamp_sig: str | None = None,
detail: Mapping[str, Any] | None = None,
) -> int:
"""One print_log row and one audit row (action `print` or `export`), in one transaction. The audit detail of an
export names its kind as databook, traveler, csv or itp."""
audit_detail: dict[str, Any] = {
"kind": EXPORT_KINDS.get(kind, kind) if action == "export" else kind,
"what": what,
"pages": pages,
"stamp_sig": stamp_sig,
}
audit_detail.update({k: v for k, v in (detail or {}).items() if v is not None})
if asset_id is not None:
audit_detail["asset_id"] = str(asset_id)
if itp_id is not None:
audit_detail["itp_id"] = itp_id
with conn.transaction():
new_id = conn.execute(
"INSERT INTO print_log (by, by_name, session_id, packet_id, unit_id, itp_id, kind, what, pages, asset_id,"
" stamp, stamp_sig, ip) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id",
(
p.user_id,
p.user.get("display_name"),
p.session_id,
packet_id,
unit_id,
itp_id,
kind,
what,
pages,
asset_id,
stamp,
stamp_sig,
p.ip,
),
).fetchone()[0]
audit.append(
conn,
action=action,
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="itp" if itp_id is not None and packet_id is None else "packet",
subject_id=itp_id if itp_id is not None and packet_id is None else packet_id,
packet_id=packet_id,
detail=audit_detail,
ip=p.ip,
)
return int(new_id)
def list_prints(conn: Any, packet_id: int, limit: int = PRINTS_LIMIT) -> list[dict[str, Any]]:
"""A packet's printed copies and exports, newest first."""
return [
{
"id": r["id"],
"at": r["at"],
"by": None if r["by"] is None else {"id": str(r["by"]), "display_name": r["by_name"]},
"kind": r["kind"],
"what": r["what"],
"pages": r["pages"],
}
for r in packets_dom.rows(
conn,
"SELECT id, at, by, by_name, kind, what, pages FROM print_log WHERE packet_id = %s"
" ORDER BY id DESC LIMIT %s",
(packet_id, limit),
)
]