Files
PacketStation/server/packetstation/pdf/style.py
T
spencermandClaude Fable 5.1 904437153d
ci / frontend (push) Successful in 40s
ci / server (push) Successful in 10m56s
ci / image (push) Successful in 48s
ci / e2e (push) Successful in 2m44s
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]>
2026-10-17 23:49:14 -06:00

228 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Shared look and helpers for the PDF builders: colours, page geometry, text cleaning, dates and the result types.
The colours, the page frame and the address lines are the old databook.js values (DBC, L, R, CW, PESCO_ADDR).
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import UTC, date, datetime
from decimal import Decimal
from typing import Any, TypedDict
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
# ---------------------------------------------------------------- colours (RGB 0..255)
NAVY = (31, 43, 69)
TEAL = (13, 127, 146)
GREY = (107, 114, 128)
LINE = (213, 217, 223)
PANEL = (244, 246, 249)
AMBER = (138, 97, 0)
AMBER_BG = (253, 246, 230)
RED = (154, 43, 43)
GREEN = (30, 110, 70)
WHITE = (255, 255, 255)
GREEN_BG = (232, 245, 238)
RED_BG = (250, 234, 234)
REMARK = (55, 65, 81)
# ---------------------------------------------------------------- page geometry (points, letter portrait)
PAGE_W = 612.0
PAGE_H = 792.0
L = 48.0
R = 564.0
CW = R - L
PESCO_ADDR = ("5680 Highway 64", "Farmington, NM 87401", "505.327.2222 • pescoinc.biz")
PESCO_NAME = "PESCO - Process Equipment & Service Company, Inc."
# ---------------------------------------------------------------- results
class PdfError(Exception):
"""A PDF could not be assembled. Per-part problems never raise this; they become warnings."""
class NothingToPrint(PdfError):
"""The print kind has nothing to print for this packet, or the sheet or document is not one of its own."""
class WhoRef(TypedDict):
"""Who builds the PDF: the user id, the display name and the employee number (text, may be None)."""
id: str | None
display_name: str | None
emp_no: str | None
class IndexRow(TypedDict):
"""One row of the data book index. section is 1..7; page is the first page number or None."""
section: int
doc: str
ref: str | None
rev: str | None
page: int | None
warn: bool
count: int
@dataclass
class Built:
"""A built PDF. `stamp` is the stamp line as printed, `stamp_sig` the 16 hex signed into it, `what` the label."""
bytes: bytes
pages: int
index: list[IndexRow] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
stamp: str = ""
stamp_sig: str = ""
what: str = ""
# ---------------------------------------------------------------- text
_MAP = str.maketrans(
{
"‘": "'",
"’": "'",
"“": '"',
"”": '"',
"–": "-",
"—": "-",
"→": "->",
"\t": " ",
}
)
_BULLET = "•"
_DROP = re.compile(r"[\x00-\x09\x0b-\x1f\x7f-\x9f]")
def clean(text: Any) -> str:
"""The old clean(): curly quotes to straight, dashes to "-", arrows to "->". Anything outside Latin-1 is
dropped except the bullet. Control characters are dropped too; a newline stays (it breaks a table cell)."""
s = "" if text is None else str(text)
s = s.replace("\r\n", "\n").replace("\r", "\n").translate(_MAP)
s = "".join(ch for ch in s if ord(ch) <= 0xFF or ch == _BULLET)
return _DROP.sub("", s)
def ascii_only(text: Any, repl: str = "") -> str:
"""Printable ASCII only: everything else becomes `repl`."""
return re.sub(r"[^\x20-\x7e]", repl, clean(text).replace("\n", " "))
def who_text(who: WhoRef | None) -> str:
"""'Name (emp)'. A missing employee number reads 'Name (?)'."""
if not who:
return "unknown"
name = str(who.get("display_name") or "").strip() or "unknown"
emp = str(who.get("emp_no") or "").strip() or "?"
return f"{name} ({emp})"
def fmt_num(value: Any) -> str:
"""A quantity or hours without trailing zeros: 2, 2.5. Text stays as it is."""
if value is None or value == "":
return ""
if isinstance(value, bool):
return str(value)
if isinstance(value, int):
return str(value)
if isinstance(value, Decimal | float):
d = Decimal(str(value)).normalize()
out = format(d, "f")
return out.rstrip("0").rstrip(".") if "." in out else out
return str(value)
def fmt_clock(ms: Any) -> str:
"""Elapsed milliseconds as h:mm."""
try:
t = max(0, int(ms or 0) // 1000)
except (TypeError, ValueError):
t = 0
return f"{t // 3600}:{t % 3600 // 60:02d}"
# ---------------------------------------------------------------- dates
def zone(tz: str | None) -> ZoneInfo:
try:
return ZoneInfo(tz or "America/Denver")
except (ZoneInfoNotFoundError, ValueError):
return ZoneInfo("America/Denver")
def as_dt(value: Any) -> datetime | date | None:
"""A datetime or date from a column value or an ISO string (jsonb snapshots store ISO text)."""
if value is None or value == "":
return None
if isinstance(value, datetime | date):
return value
if isinstance(value, int | float):
try:
return datetime.fromtimestamp(float(value) / 1000, UTC)
except (OverflowError, OSError, ValueError):
return None
try:
return datetime.fromisoformat(str(value).strip().replace("Z", "+00:00"))
except ValueError:
return None
def _local(value: Any, tz: str | None) -> datetime | date | None:
v = as_dt(value)
if isinstance(v, datetime) and v.tzinfo is not None:
return v.astimezone(zone(tz))
return v
def fmt_d(value: Any, tz: str | None = None, empty: str = "—") -> str:
"""M/D/YYYY in the site time zone."""
v = _local(value, tz)
if v is None:
return empty
return f"{v.month}/{v.day}/{v.year}"
def fmt_dt(value: Any, tz: str | None = None, empty: str = "—") -> str:
"""M/D/YYYY h:mm AM in the site time zone. A plain date gives the date only."""
v = _local(value, tz)
if v is None:
return empty
if not isinstance(v, datetime):
return f"{v.month}/{v.day}/{v.year}"
h = v.hour % 12 or 12
return f"{v.month}/{v.day}/{v.year} {h}:{v.minute:02d} {'AM' if v.hour < 12 else 'PM'}"
def utc_now(now: datetime | None = None) -> datetime:
n = now or datetime.now(UTC)
if n.tzinfo is None:
n = n.replace(tzinfo=UTC)
return n.astimezone(UTC)
def utc_stamp(now: datetime | None = None) -> str:
"""YYYY-MM-DD HH:MM UTC."""
return utc_now(now).strftime("%Y-%m-%d %H:%M UTC")
def stamp_at(now: datetime | None = None) -> str:
"""The minute a stamp names, as signed into the stamp signature: YYYY-MM-DDTHH:MMZ."""
return utc_now(now).strftime("%Y-%m-%dT%H:%MZ")
def pdf_date(now: datetime | None = None) -> str:
"""A PDF date string: D:YYYYMMDDHHmmSSZ."""
return utc_now(now).strftime("D:%Y%m%d%H%M%SZ")
def site_tz(settings: Any) -> str:
return str(getattr(settings, "site_tz", None) or "America/Denver")