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
+280
View File
@@ -0,0 +1,280 @@
"""Stamps and metadata: the controlled-copy line, the PRINTED COPY box, the stamp signature, the VOID overlay on a
copied drawing page, and the document information.
The stamp signature is 16 hex characters of HMAC-SHA256 with the seal key over
"stamp|{packet_id}|{kind}|{seal hash}|{user id}|{minute}". The minute is the one the stamp line names, as
YYYY-MM-DDTHH:MMZ (style.stamp_at), so a printed copy can be checked from its own stamp and the print log.
"""
from __future__ import annotations
import hashlib
import hmac
import io
import math
from collections.abc import Callable, Iterable, Mapping
from datetime import datetime
from typing import Any
from pypdf import PdfReader, PdfWriter, Transformation
from reportlab.lib.colors import Color
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen.canvas import Canvas
from .style import GREY, WhoRef, as_dt, ascii_only, pdf_date, stamp_at, utc_stamp, who_text
AUTHOR = "PESCO - Process Equipment & Service Company, Inc."
PRODUCER = "Packet Station"
SIG_LEN = 16
Draw = Callable[[Canvas, int, float, float], None]
# ---------------------------------------------------------------- texts
def controlled_stamp(who: WhoRef | None, now: datetime | None, *, mode: str, seal_hash_hex: str | None = None) -> str:
"""The controlled-copy line. mode "filed" names the seal (first 12 hex), "preview" says not yet filed, "print"
says printed by (and the seal or not yet filed), "traveler" carries no seal part."""
verb = "printed" if mode == "print" else "exported"
text = f"Controlled copy - {verb} by {who_text(who)} {utc_stamp(now)}"
if mode != "traveler":
text += f" - seal {seal_hash_hex[:12]}" if seal_hash_hex else " - not yet filed"
return ascii_only(text)
def project_lot(packet: Mapping[str, Any] | None) -> tuple[str, str]:
"""The project number and the lot of a packet read model, as text."""
unit = (packet or {}).get("unit") or {}
project = unit.get("project") or {}
return str(project.get("number") or ""), str(unit.get("lot") or "")
def print_stamp(who: WhoRef | None, now: datetime | None, what: str, packet: Mapping[str, Any] | None) -> str:
"""PRINTED COPY - {what} - printed by Name (emp) {UTC} - {project} Lot {unit} dwg {drawing} R{rev} - verify
revision before use. Anything outside printable ASCII becomes "-"."""
text = f"PRINTED COPY - {what} - printed by {who_text(who)} {utc_stamp(now)}"
if packet is not None:
project, lot = project_lot(packet)
text += f" - {project} Lot {lot} dwg {packet.get('drawing') or ''} R{packet.get('rev') or ''}"
text += " - verify revision before use"
return ascii_only(text, "-")
def minute_of(at_iso: Any) -> str:
"""The signed minute: any ISO time (or datetime) as YYYY-MM-DDTHH:MMZ in UTC. Text that does not parse is
signed as it is."""
v = as_dt(at_iso)
if isinstance(v, datetime):
return stamp_at(v)
return str(at_iso or "")
def stamp_sig(key: bytes, packet_id: Any, kind: str, seal_hash_hex: str | None, user_id: Any, at_iso: Any) -> str:
"""16 hex characters of HMAC-SHA256 over "stamp|" + the fields joined with "|". The time is signed to the
minute the stamp line shows (minute_of), so a stamp can be checked from its own text."""
parts = [
str(packet_id if packet_id is not None else ""),
str(kind),
seal_hash_hex or "",
str(user_id or ""),
minute_of(at_iso),
]
msg = ("stamp|" + "|".join(parts)).encode("utf-8")
return hmac.new(key, msg, hashlib.sha256).hexdigest()[:SIG_LEN]
def verify_stamp(
key: bytes, packet_id: Any, kind: str, seal_hash_hex: str | None, user_id: Any, at_iso: Any, sig: str
) -> bool:
"""True when `sig` is the stamp signature of these fields (the stamp_sig arguments, then the signature)."""
want = stamp_sig(key, packet_id, kind, seal_hash_hex, user_id, at_iso)
return hmac.compare_digest(want, str(sig or "").strip().lower())
def with_sig(text: str, sig: str) -> str:
return f"{text} - stamp {sig}" if sig else text
def signed_stamp(
settings: Any,
text: str,
*,
packet_id: Any,
kind: str,
seal_hash_hex: str | None,
who: WhoRef | None,
now: datetime | None,
warnings: list[str] | None = None,
) -> tuple[str, str]:
"""The stamp line with its signature, and the signature. Without a readable seal key the line goes out unsigned
and a warning is added."""
try:
key = settings.seal_key()
except Exception: # the key file is missing or unreadable: stamp without a signature
if warnings is not None:
warnings.append("The seal key is not available, so the stamp is not signed.")
return text, ""
sig = stamp_sig(key, packet_id, kind, seal_hash_hex, who.get("id") if who else None, stamp_at(now))
return with_sig(text, sig), sig
# ---------------------------------------------------------------- drawing on pages
def draw_line_stamp(c: Canvas, w: float, text: str, *, own: bool) -> None:
"""The controlled-copy line: on our pages at x=48, y=9 in 6.5 pt grey; on copied pages centred at y=3 in 6 pt."""
c.setFillColor(Color(GREY[0] / 255, GREY[1] / 255, GREY[2] / 255))
if own:
c.setFont("Helvetica", 6.5)
c.drawString(48, 9, text)
else:
c.setFont("Helvetica", 6)
c.drawString(max(2.0, (w - stringWidth(text, "Helvetica", 6)) / 2), 3, text)
def draw_box_stamp(c: Canvas, w: float, text: str) -> None:
"""The old print stamp: a white box at 92 % opacity at the bottom centre, the text in #333."""
fs = min(12.0, max(7.0, w / 150))
tw = stringWidth(text, "Helvetica", fs)
room = w - fs * 1.2 - 4
if tw > room:
fs = max(3.5, fs * room / tw)
tw = stringWidth(text, "Helvetica", fs)
pad = fs * 0.5
bh = fs * 1.6
x = max(pad, (w - tw) / 2)
y0 = pad * 0.4
c.saveState()
c.setFillColor(Color(1, 1, 1))
c.setFillAlpha(0.92)
c.rect(x - pad, y0, tw + pad * 2, bh, stroke=0, fill=1)
c.restoreState()
c.setFont("Helvetica", fs)
c.setFillColor(Color(0x33 / 255, 0x33 / 255, 0x33 / 255))
c.drawString(x, y0 + bh / 2 - fs * 0.35, text)
def draw_void(c: Canvas, w: float, h: float, sub: str) -> None:
"""VOID on a copied drawing page: HelveticaBold w/5.5 along the diagonal at 30 % red, the void text at 12 %
height in w/70, a red border inset 4 pt, width max(3, w/250), at 70 %."""
size = w / 5.5
tw = stringWidth("VOID", "Helvetica-Bold", size)
ang = math.atan2(h, w) * 0.85
x = w / 2 - math.cos(ang) * tw / 2 + math.sin(ang) * size * 0.35
y = h / 2 - math.sin(ang) * tw / 2 - math.cos(ang) * size * 0.35
red = Color(0.8, 0, 0)
c.saveState()
c.setFillColor(red)
c.setFillAlpha(0.3)
c.translate(x, y)
c.rotate(math.degrees(ang))
c.setFont("Helvetica-Bold", size)
c.drawString(0, 0, "VOID")
c.restoreState()
text = ascii_only(sub, "-")
ss = w / 70
c.setFillColor(red)
c.setFont("Helvetica-Bold", ss)
c.drawString(w / 2 - stringWidth(text, "Helvetica-Bold", ss) / 2, h * 0.12, text)
c.saveState()
c.setStrokeColor(red)
c.setStrokeAlpha(0.7)
c.setLineWidth(max(3.0, w / 250))
c.rect(4, 4, w - 8, h - 8, stroke=1, fill=0)
c.restoreState()
# ---------------------------------------------------------------- overlay
def page_size(page: Any) -> tuple[float, float, float, float]:
"""(llx, lly, width, height) of a pypdf page's MediaBox."""
box = page.mediabox
return float(box.left), float(box.bottom), float(box.width), float(box.height)
def overlay_writer(writer: PdfWriter, draw: Draw, pages: Iterable[int] | None = None) -> None:
"""Draw on every page (or the given page indices) of a writer: one reportlab page per page, merged on top.
A page with /Rotate has its rotation moved into its content first, so the overlay reads upright."""
wanted = list(range(len(writer.pages))) if pages is None else sorted(set(pages))
if not wanted:
return
buf = io.BytesIO()
c = Canvas(buf, pageCompression=1)
boxes: list[tuple[float, float]] = []
for i in wanted:
page = writer.pages[i]
if int(page.get("/Rotate", 0) or 0) % 360:
page.transfer_rotation_to_content()
llx, lly, w, h = page_size(page)
boxes.append((llx, lly))
c.setPageSize((w, h))
draw(c, i, w, h)
c.showPage()
c.save()
ov = PdfReader(io.BytesIO(buf.getvalue()))
for n, i in enumerate(wanted):
llx, lly = boxes[n]
if llx or lly:
writer.pages[i].merge_transformed_page(ov.pages[n], Transformation().translate(llx, lly))
else:
writer.pages[i].merge_page(ov.pages[n])
def overlay(pdf_bytes: bytes, draw: Draw, *, metadata: Mapping[str, str] | None = None) -> bytes:
writer = PdfWriter(clone_from=PdfReader(io.BytesIO(pdf_bytes)))
overlay_writer(writer, draw)
if metadata:
writer.add_metadata(dict(metadata))
out = io.BytesIO()
writer.write(out)
return out.getvalue()
def apply_stamp(pdf_bytes: bytes, text: str, *, ours: set[int] | None = None, box: bool = False) -> bytes:
"""Draw the stamp line on every page. `ours` are the 0-based indices of our generated pages (None: all are
ours). box=True draws the PRINTED COPY box instead."""
def draw(c: Canvas, i: int, w: float, h: float) -> None:
del h
if box:
draw_box_stamp(c, w, text)
else:
draw_line_stamp(c, w, text, own=ours is None or i in ours)
return overlay(pdf_bytes, draw)
def metadata(title: str, subject: str, keywords: str, creator: str, *, now: datetime | None = None) -> dict[str, str]:
stamp = pdf_date(now)
return {
"/Title": ascii_only(title, "-"),
"/Author": AUTHOR,
"/Subject": ascii_only(subject, "-"),
"/Keywords": ascii_only(keywords, "-"),
"/Creator": ascii_only(creator, "-"),
"/Producer": PRODUCER,
"/CreationDate": stamp,
"/ModDate": stamp,
}
def set_metadata(
pdf_bytes: bytes, title: str, subject: str, keywords: str, creator: str, *, now: datetime | None = None
) -> bytes:
"""The document information: title, subject, keywords, creator, Author PESCO, Producer Packet Station and the
creation date."""
writer = PdfWriter(clone_from=PdfReader(io.BytesIO(pdf_bytes)))
writer.add_metadata(metadata(title, subject, keywords, creator, now=now))
out = io.BytesIO()
writer.write(out)
return out.getvalue()
def write(writer: PdfWriter, meta: Mapping[str, str] | None = None) -> bytes:
if meta:
writer.add_metadata(dict(meta))
out = io.BytesIO()
writer.write(out)
return out.getvalue()