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:
@@ -14,12 +14,16 @@ Child-row endpoints build on the same pieces: `check_row_version`, `next_counter
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import Request
|
||||
@@ -45,6 +49,16 @@ MSG_STALE = "Someone else already took this step."
|
||||
MSG_VERSION = "The packet changed since you opened it. Reload and try again."
|
||||
MSG_ROW = "Someone else changed this line. Reload and try again."
|
||||
MSG_FIELD = "Someone else changed this since you opened it."
|
||||
MSG_DATABOOK = "The data book could not be built: {reason}. Nothing was filed."
|
||||
|
||||
|
||||
def open_wos_text(wos: Sequence[str]) -> str:
|
||||
"""The FILE confirm question: "VISUAL still shows 2 work orders open on this packet: L489052/1, L489052/2. File
|
||||
anyway?" """
|
||||
n = len(wos)
|
||||
what = "1 work order" if n == 1 else f"{n} work orders"
|
||||
return f"VISUAL still shows {what} open on this packet: {', '.join(wos)}. File anyway?"
|
||||
|
||||
|
||||
# Codes that answer 400 (the UI asks for input and retries); every other refusal answers 403.
|
||||
ASK_CODES = frozenset({"it_reason_required", "reason_required"})
|
||||
@@ -143,6 +157,17 @@ class Write:
|
||||
def rule(self) -> policy.Rule:
|
||||
return policy.RULES[self.action]
|
||||
|
||||
@property
|
||||
def settings(self) -> Any:
|
||||
return _settings(self)
|
||||
|
||||
@property
|
||||
def actor_ref(self) -> Any:
|
||||
"""The signed-in person as the PDF builders' WhoRef: id, display name and employee number."""
|
||||
from . import prints
|
||||
|
||||
return prints.who_of(self.principal)
|
||||
|
||||
def log(self, action_label: str, note: str | None = None) -> None:
|
||||
"""Override the packet_log label (and note) for this write."""
|
||||
self.label = action_label
|
||||
@@ -323,6 +348,11 @@ def packet_write(
|
||||
bypass = bypass or dd.admin_bypass
|
||||
if if_match is not None and int(if_match) != int(row["version"]):
|
||||
raise ApiError(409, "version_conflict", MSG_VERSION, extra={"current": current_of(row)})
|
||||
if action is A.FILE and extra.get("confirm_open_wos") is not True:
|
||||
# Before the signature: a cancelled confirm costs no attempt and writes nothing.
|
||||
wos = packets.packet_open_wos(conn, row)
|
||||
if wos:
|
||||
raise ApiError(409, "open_work_orders", open_wos_text(wos), extra={"work_orders": wos})
|
||||
w = Write(
|
||||
conn=conn,
|
||||
principal=p,
|
||||
@@ -770,14 +800,7 @@ def apply_transition(w: Write) -> None:
|
||||
_set_packet(w, "status = %s::packet_status, status_at = now(), dept_code = %s", (to_status, to_dept))
|
||||
|
||||
if a is A.FILE:
|
||||
key = _seal_key(w)
|
||||
row = packets.load_row(w.conn, w.id)
|
||||
assert row is not None
|
||||
content = packets.seal_content(w.conn, row)
|
||||
w.conn.execute(
|
||||
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by, signature_id) VALUES (%s, %s, %s, %s, %s)",
|
||||
(w.id, seal_hash(key, content), seal_key_id(key), w.user_id, w.signature.id if w.signature else None),
|
||||
)
|
||||
_file(w)
|
||||
elif a is A.ADMIN_REOPEN:
|
||||
w.conn.execute(
|
||||
"UPDATE packet_seals SET broken_at = now(), broken_by = %s, broken_reason = %s"
|
||||
@@ -807,6 +830,84 @@ def apply_transition(w: Write) -> None:
|
||||
w.note = reason or _note(w)
|
||||
|
||||
|
||||
def _file(w: Write) -> None:
|
||||
"""FILE: the data book date, the seal, and the data book built and stored on the seal, all in this transaction.
|
||||
|
||||
A data book that cannot be built answers 500 databook_failed and the whole filing rolls back."""
|
||||
key = _seal_key(w)
|
||||
_set_packet(w, "databook_at = now()")
|
||||
row = packets.load_row(w.conn, w.id)
|
||||
assert row is not None
|
||||
wos = packets.packet_open_wos(w.conn, row) if w.extra.get("confirm_open_wos") is True else []
|
||||
content = packets.seal_content(w.conn, row)
|
||||
digest = seal_hash(key, content)
|
||||
kid = seal_key_id(key)
|
||||
seal_id, sealed_at = w.conn.execute(
|
||||
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by, signature_id, open_wos)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s) RETURNING id, sealed_at",
|
||||
(w.id, digest, kid, w.user_id, w.signature.id if w.signature else None, wos or None),
|
||||
).fetchone()
|
||||
pages = _file_databook(w, row, {"hash": digest.hex(), "key_id": kid, "sealed_at": sealed_at}, int(seal_id))
|
||||
w.log_also("Data book filed", f"{pages} pages")
|
||||
if wos:
|
||||
w.note = " · ".join(x for x in (_note(w), "Filed with work orders open in VISUAL: " + ", ".join(wos)) if x)
|
||||
w.detail["open_wos"] = wos
|
||||
w.detail["seal_id"] = int(seal_id)
|
||||
w.detail["databook_pages"] = pages
|
||||
|
||||
|
||||
def _file_databook(w: Write, row: Mapping[str, Any], seal: dict[str, Any], seal_id: int) -> int:
|
||||
"""Build the filed data book, store it as an asset and put it on the seal row. Returns its page count."""
|
||||
from ..pdf import databook
|
||||
from ..pdf.style import PdfError
|
||||
from . import assets
|
||||
from . import prints as prints_dom
|
||||
|
||||
settings = _settings(w)
|
||||
try:
|
||||
built = databook.build(w.conn, settings, w.id, who=w.actor_ref, seal=seal, mode="filed")
|
||||
except PdfError as e:
|
||||
log.warning("data book for packet %s failed: %s", w.id, e)
|
||||
raise ApiError(500, "databook_failed", MSG_DATABOOK.format(reason=_reason_text(e))) from None
|
||||
read = packets.read_packet(w.conn, w.id, None, include_children=False)
|
||||
name = prints_dom.databook_name(read)
|
||||
tmp_dir = Path(settings.assets_dir) / "tmp"
|
||||
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(prefix="databook-", suffix=".pdf", dir=tmp_dir)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(built.bytes)
|
||||
try:
|
||||
stored = assets.store_file(
|
||||
w.conn,
|
||||
settings,
|
||||
Path(tmp_name),
|
||||
mime=assets.PDF,
|
||||
original_name=name,
|
||||
source="databook",
|
||||
source_ref={"packet_id": w.id, "seal_id": seal_id},
|
||||
uploaded_by=w.user_id,
|
||||
packet_id=w.id,
|
||||
)
|
||||
except ApiError as e:
|
||||
log.warning("data book for packet %s was not stored: %s", w.id, e.detail)
|
||||
raise ApiError(500, "databook_failed", MSG_DATABOOK.format(reason=e.detail.rstrip("."))) from None
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
Path(tmp_name).unlink()
|
||||
pages = int(built.pages)
|
||||
w.conn.execute(
|
||||
"UPDATE packet_seals SET databook_asset_id = %s, databook_pages = %s WHERE id = %s",
|
||||
(stored["id"], pages, seal_id),
|
||||
)
|
||||
w.detail["databook_asset_id"] = str(stored["id"])
|
||||
return pages
|
||||
|
||||
|
||||
def _reason_text(e: BaseException) -> str:
|
||||
text = " ".join(str(e).split()).rstrip(".")
|
||||
return text[:300] or "an unknown error"
|
||||
|
||||
|
||||
def verify_seal(conn: Any, settings: Any, packet_id: int) -> dict[str, Any]:
|
||||
"""The seal history of a packet and whether the current unbroken seal still matches the packet."""
|
||||
seals = packets.rows(conn, "SELECT * FROM packet_seals WHERE packet_id = %s ORDER BY id DESC", (packet_id,))
|
||||
@@ -834,6 +935,9 @@ def verify_seal(conn: Any, settings: Any, packet_id: int) -> dict[str, Any]:
|
||||
"broken_at": s["broken_at"],
|
||||
"broken_by": people.ref(s["broken_by"]),
|
||||
"broken_reason": s["broken_reason"],
|
||||
"open_wos": None if s.get("open_wos") is None else list(s["open_wos"]),
|
||||
"databook_asset_id": None if s.get("databook_asset_id") is None else str(s["databook_asset_id"]),
|
||||
"databook_pages": s.get("databook_pages"),
|
||||
}
|
||||
|
||||
return {"seal": shape(current) if current else None, "valid": valid, "seals": [shape(s) for s in seals]}
|
||||
|
||||
Reference in New Issue
Block a user