Phase 7: close, file, PDFs and prints
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:
@@ -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()
|
||||
Reference in New Issue
Block a user