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]>
228 lines
6.7 KiB
Python
228 lines
6.7 KiB
Python
"""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")
|