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]>
236 lines
8.3 KiB
Python
236 lines
8.3 KiB
Python
"""What the builders share: the packet read model, people with their employee numbers, departments, the packet
|
|
log, and single pages made from a raster or an image.
|
|
|
|
Everything here only reads. The read model comes from domain/packets.py `build` (outside seal mode, no principal).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import uuid
|
|
from collections.abc import Iterable, Mapping
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from pypdf import PdfReader
|
|
from reportlab.lib.utils import ImageReader
|
|
from reportlab.pdfgen.canvas import Canvas
|
|
|
|
from ..domain import packets
|
|
from .style import PAGE_H, PAGE_W, PdfError
|
|
|
|
# The status texts of the frontend's lib/packets.ts STATUS_LABEL.
|
|
STATUS_LABEL = {
|
|
"draft": "Ready to release",
|
|
"warehouse": "With warehouse",
|
|
"ready": "Ready to assign",
|
|
"assigned": "Sent to station",
|
|
"in_progress": "Working",
|
|
"review": "Supervisor review",
|
|
"qc": "Q.C. inspection",
|
|
"complete": "Routed",
|
|
"filed": "Filed in data book",
|
|
}
|
|
|
|
# The old ROLE_N texts, extended to the role kinds of auth/groups.py.
|
|
ROLE_TEXT = {
|
|
"admin": "Admin",
|
|
"clerical": "Clerical",
|
|
"clerical_supervisor": "Clerical supervisor",
|
|
"warehouse": "Warehouse",
|
|
"warehouse_supervisor": "Warehouse supervisor",
|
|
"qc": "Q.C.",
|
|
"qc_supervisor": "Q.C. supervisor",
|
|
"pm": "Project Mgmt",
|
|
"shop_worker": "Worker",
|
|
"shop_lead": "Shop lead",
|
|
"shop_supervisor": "Supervisor",
|
|
"supervisor": "Supervisor",
|
|
"stall": "Weld Stall",
|
|
}
|
|
|
|
|
|
def load_packet(conn: Any, packet_id: int) -> dict[str, Any]:
|
|
"""The packet read model (outside seal mode, no principal). PdfError when there is no such packet."""
|
|
row = packets.load_row(conn, int(packet_id))
|
|
if row is None:
|
|
raise PdfError("No such packet.")
|
|
return packets.build(conn, row, None)
|
|
|
|
|
|
def _uuid(v: Any) -> uuid.UUID | None:
|
|
if v is None:
|
|
return None
|
|
if isinstance(v, Mapping):
|
|
v = v.get("id")
|
|
try:
|
|
return uuid.UUID(str(v))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def users_of(conn: Any, ids: Iterable[Any]) -> dict[str, dict[str, Any]]:
|
|
"""{user id text: {display_name, emp_no, stamp}} for the given ids (user refs or ids)."""
|
|
want = sorted({u for u in (_uuid(i) for i in ids) if u is not None}, key=str)
|
|
if not want:
|
|
return {}
|
|
out: dict[str, dict[str, Any]] = {}
|
|
for r in conn.execute("SELECT id, display_name, emp_no, welder_stamp FROM users WHERE id = ANY(%s)", (want,)):
|
|
out[str(r[0])] = {"display_name": r[1], "emp_no": r[2], "stamp": r[3]}
|
|
return out
|
|
|
|
|
|
def name_emp(users: Mapping[str, Mapping[str, Any]], ref: Any, fallback: str = "") -> str:
|
|
"""'Name (emp)' for a user ref or id; the fallback name when the user is unknown."""
|
|
u = _uuid(ref)
|
|
info = users.get(str(u)) if u else None
|
|
name = (info or {}).get("display_name") or (ref.get("display_name") if isinstance(ref, Mapping) else None)
|
|
name = name or fallback
|
|
if not name:
|
|
return ""
|
|
emp = (info or {}).get("emp_no") or "?"
|
|
return f"{name} ({emp})"
|
|
|
|
|
|
def name_of(users: Mapping[str, Mapping[str, Any]], ref: Any, fallback: str = "") -> str:
|
|
u = _uuid(ref)
|
|
info = users.get(str(u)) if u else None
|
|
name = (info or {}).get("display_name") or (ref.get("display_name") if isinstance(ref, Mapping) else None)
|
|
return str(name or fallback)
|
|
|
|
|
|
def departments(conn: Any) -> dict[str, dict[str, Any]]:
|
|
"""{code: {name, label, weld}}. The label is "03 Weld Parts" for a shop department, the name otherwise."""
|
|
out: dict[str, dict[str, Any]] = {}
|
|
for code, name, kind, weld in conn.execute("SELECT code, name, kind::text, weld FROM departments"):
|
|
out[code] = {"name": name, "label": packets.dept_label(code, name, kind), "weld": bool(weld)}
|
|
return out
|
|
|
|
|
|
def role_text(role: Any) -> str:
|
|
"""The role snapshot of a log row in words."""
|
|
parts = []
|
|
for r in str(role or "").split(","):
|
|
r = r.strip()
|
|
if not r:
|
|
continue
|
|
bits = r.split(":")
|
|
if bits[0] == "shop" and len(bits) == 3: # shop:03:supervisor
|
|
parts.append(f"{bits[1]} {ROLE_TEXT.get('shop_' + bits[2], bits[2].replace('_', ' '))}")
|
|
else:
|
|
parts.append(ROLE_TEXT.get(bits[0], bits[0].replace("_", " ")))
|
|
return ", ".join(dict.fromkeys(parts))
|
|
|
|
|
|
def log_rows(conn: Any, packet_id: int) -> list[dict[str, Any]]:
|
|
"""Every packet_log row oldest first, with the signer's name and employee number on signed rows (the log
|
|
endpoint's columns, no limit)."""
|
|
return packets.rows(
|
|
conn,
|
|
"SELECT l.id, l.at, l.by, l.by_name, l.role, l.action, l.note, l.it_reason, l.signature_id,"
|
|
" u.emp_no, s.display_name AS sig_name, su.emp_no AS sig_emp, su.welder_stamp AS sig_stamp"
|
|
" FROM packet_log l LEFT JOIN users u ON u.id = l.by"
|
|
" LEFT JOIN signatures s ON s.id = l.signature_id LEFT JOIN users su ON su.id = s.user_id"
|
|
" WHERE l.packet_id = %s ORDER BY l.id",
|
|
(int(packet_id),),
|
|
)
|
|
|
|
|
|
def shorts_of(conn: Any, packet_id: int) -> list[dict[str, Any]]:
|
|
"""Every short of the packet, open and closed, with its BOM item and its receipts."""
|
|
found = packets.rows(
|
|
conn,
|
|
"SELECT s.*, b.item FROM bom_shorts s JOIN bom_lines b ON b.id = s.bom_line_id"
|
|
" WHERE s.packet_id = %s ORDER BY b.item, s.id",
|
|
(int(packet_id),),
|
|
)
|
|
if not found:
|
|
return []
|
|
rec: dict[int, list[dict[str, Any]]] = {}
|
|
for r in packets.rows(
|
|
conn, "SELECT * FROM short_receipts WHERE short_id = ANY(%s) ORDER BY id", ([s["id"] for s in found],)
|
|
):
|
|
rec.setdefault(r["short_id"], []).append(r)
|
|
for s in found:
|
|
s["receipts"] = rec.get(s["id"], [])
|
|
return found
|
|
|
|
|
|
def project_of(p: Mapping[str, Any]) -> dict[str, Any]:
|
|
return dict(((p.get("unit") or {}).get("project")) or {})
|
|
|
|
|
|
def tag_of(p: Mapping[str, Any]) -> tuple[str, str]:
|
|
"""(project number, lot) as text."""
|
|
unit = p.get("unit") or {}
|
|
return str(project_of(p).get("number") or ""), str(unit.get("lot") or "")
|
|
|
|
|
|
# ---------------------------------------------------------------- pages
|
|
|
|
|
|
def drawing_pages(p: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
"""The live drawing pages in page order."""
|
|
return [pg for pg in p.get("pages") or [] if pg.get("type") == "drawing" and not pg.get("void")]
|
|
|
|
|
|
def void_pages(p: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
return [pg for pg in p.get("pages") or [] if pg.get("type") == "drawing" and pg.get("void")]
|
|
|
|
|
|
def page_co(p: Mapping[str, Any], n: int) -> int | None:
|
|
"""The change order a live page came with, from the change order items (new_page)."""
|
|
for co in p.get("change_orders") or []:
|
|
for it in co.get("items") or []:
|
|
if it.get("new_page") == n:
|
|
return co.get("co_no")
|
|
return None
|
|
|
|
|
|
def open_pdf(path: Path) -> PdfReader:
|
|
"""A PdfReader for a stored file. An encrypted file is opened with an empty password or refused."""
|
|
reader = PdfReader(str(path))
|
|
if reader.is_encrypted and not reader.decrypt(""):
|
|
raise PdfError("The PDF is encrypted.")
|
|
if len(reader.pages) < 1:
|
|
raise PdfError("The PDF has no pages.")
|
|
return reader
|
|
|
|
|
|
def raster_page_pdf(jpeg: bytes, w: float, h: float) -> bytes:
|
|
"""One page of the sheet's size with the JPEG filling it."""
|
|
buf = io.BytesIO()
|
|
c = Canvas(buf, pagesize=(w, h), pageCompression=1)
|
|
c.drawImage(ImageReader(io.BytesIO(jpeg)), 0, 0, w, h)
|
|
c.showPage()
|
|
c.save()
|
|
return buf.getvalue()
|
|
|
|
|
|
def image_page_pdf(path: Path) -> bytes:
|
|
"""A PNG or JPEG on a letter page, fit inside 540 x 700 and centred."""
|
|
img = ImageReader(str(path))
|
|
iw, ih = img.getSize()
|
|
if iw <= 0 or ih <= 0:
|
|
raise PdfError("The image is empty.")
|
|
sc = min(540 / iw, 700 / ih)
|
|
buf = io.BytesIO()
|
|
c = Canvas(buf, pagesize=(PAGE_W, PAGE_H), pageCompression=1)
|
|
c.drawImage(img, (PAGE_W - iw * sc) / 2, (PAGE_H - ih * sc) / 2, iw * sc, ih * sc, mask="auto")
|
|
c.showPage()
|
|
c.save()
|
|
return buf.getvalue()
|
|
|
|
|
|
def sniff(path: Path) -> str | None:
|
|
"""pdf, png or jpeg from the first bytes, or None."""
|
|
with path.open("rb") as f:
|
|
head = f.read(8)
|
|
if head.startswith(b"%PDF"):
|
|
return "pdf"
|
|
if head.startswith(b"\x89PNG"):
|
|
return "png"
|
|
if head.startswith(b"\xff\xd8"):
|
|
return "jpeg"
|
|
return None
|