The Project Management screen (/pm, view only) with Overview, Projects & units, Bottlenecks & aging, Hours vs target, Quality & issues and Find jobs, the period picker, health per unit and per project, drill down, Excel (CSV) per table and for all, and the PESCO production status report PDF with the controlled-copy stamp; every export logged as "report exported". The find bar on every home screen: the full search set, the dropdowns, quick flags with Mine, the flag kinds, Board and List with sorts, filters saved per browser; every filter applied by the server on the list and the boards, with facets. Presence in packets with role, lock and revoke drop; the motion switch with reduced motion honoured, card glides, glow and count-up; Back restores the scroll spot; sheet placement and the iPad layout pass. The data book compared page by page with the old build and six layout differences fixed. Tests for the PM aggregates, search, filters, presence and exports; Playwright scenarios for the PM screen, the find bar and presence at desktop and iPad viewports. Docs updated; PLAN.md questions 38 to 42. Co-Authored-By: Claude Fable 5.1 <[email protected]>
284 lines
11 KiB
Python
284 lines
11 KiB
Python
"""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, "report" (the PM production
|
|
status report) names Packet Station and carries no seal part."""
|
|
if mode == "report":
|
|
return ascii_only(f"Controlled copy - Packet Station - printed by {who_text(who)} {utc_stamp(now)}")
|
|
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()
|