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:
@@ -0,0 +1,346 @@
|
||||
"""Printed copies and exports of a packet: the data book and traveler PDFs, the traceability CSV, the print kinds and
|
||||
the print log.
|
||||
|
||||
Reads only: any signed-in role that can open the packet may view and download them (the PM role too). Each answer
|
||||
writes one print_log row and one audit row (`print` or `export`) after the bytes were built. A removed or unknown
|
||||
packet is 404. The PDF builders live in packetstation.pdf and are imported when a request needs them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
|
||||
from ..domain import assets as asset_store
|
||||
from ..domain import packets
|
||||
from ..domain import prints as prints_dom
|
||||
from .deps import Db, Principal, current_principal, get_db, settings_of
|
||||
from .errors import ApiError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/packets", tags=["prints"])
|
||||
|
||||
PDF = "application/pdf"
|
||||
CSV = "text/csv; charset=utf-8"
|
||||
|
||||
PRINT_KINDS = ("traveler", "map-h", "map-w", "clean", "void", "databook", "doc")
|
||||
SHEET_KINDS = ("map-h", "map-w", "clean")
|
||||
WHAT = {
|
||||
"traveler": "Job traveler",
|
||||
"map-h": "Drawing with heat map",
|
||||
"map-w": "Drawing with weld map",
|
||||
"clean": "Drawing (no markup)",
|
||||
"void": "Superseded drawings (VOID)",
|
||||
"databook": "Data book",
|
||||
}
|
||||
BOOK_STATUSES = ("complete", "filed")
|
||||
MSG_BOOK_STATUS = "The data book is built once the packet is back from Q.C."
|
||||
MSG_NOTHING = "Nothing to print."
|
||||
MSG_PDF = "The PDF could not be built: {reason}."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- shared helpers (api/itp.py uses them too)
|
||||
|
||||
|
||||
def file_response(data: bytes, filename: str, *, download: bool, media_type: str = PDF) -> Response:
|
||||
"""The bytes with Content-Disposition (inline unless download), nosniff and no-store."""
|
||||
return Response(
|
||||
content=data,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Disposition": prints_dom.disposition(filename, download),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def pdf_failed(e: BaseException) -> ApiError:
|
||||
text = " ".join(str(e).split()).rstrip(".")[:300] or "an unknown error"
|
||||
return ApiError(500, "pdf_failed", MSG_PDF.format(reason=text))
|
||||
|
||||
|
||||
def now_utc() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def stamp_of(built: Any) -> tuple[str | None, str | None]:
|
||||
"""The stamp line and its signature, when the builder reports them."""
|
||||
stamp = getattr(built, "stamp", None) or None
|
||||
sig = getattr(built, "stamp_sig", None) or None
|
||||
return (None if stamp is None else str(stamp)), (None if sig is None else str(sig))
|
||||
|
||||
|
||||
def _packet(conn: Any, packet_id: int) -> dict[str, Any]:
|
||||
"""The packets row; 404 for an unknown or removed packet."""
|
||||
row = packets.load_row(conn, packet_id)
|
||||
if row is None or row["removed_at"] is not None:
|
||||
raise ApiError(404, "not_found", "No such packet.")
|
||||
return row
|
||||
|
||||
|
||||
def _read(conn: Any, packet_id: int) -> dict[str, Any]:
|
||||
return packets.read_packet(conn, packet_id, None)
|
||||
|
||||
|
||||
def _filed_book(conn: Any, packet_id: int) -> dict[str, Any] | None:
|
||||
"""The unbroken seal's stored data book asset row, with the page count, or None."""
|
||||
s = packets.one(
|
||||
conn,
|
||||
"SELECT databook_asset_id, databook_pages FROM packet_seals WHERE packet_id = %s AND broken_at IS NULL"
|
||||
" AND databook_asset_id IS NOT NULL ORDER BY id DESC LIMIT 1",
|
||||
(packet_id,),
|
||||
)
|
||||
if s is None:
|
||||
return None
|
||||
a = asset_store.get(conn, s["databook_asset_id"])
|
||||
if a is None:
|
||||
return None
|
||||
return {**a, "pages": s["databook_pages"]}
|
||||
|
||||
|
||||
def _asset_bytes(request: Request, row: dict[str, Any]) -> bytes:
|
||||
return asset_store.open_blob(settings_of(request), row).read_bytes()
|
||||
|
||||
|
||||
def _who(p: Principal) -> Any:
|
||||
return prints_dom.who_of(p)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- exports
|
||||
|
||||
|
||||
@router.get("/{packet_id}/pdf/databook")
|
||||
def databook_pdf(
|
||||
packet_id: int,
|
||||
request: Request,
|
||||
download: int = Query(0, ge=0, le=1),
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> Response:
|
||||
"""The filed data book as stored, or (before filing) one built now and stamped "not yet filed"."""
|
||||
conn = d.conn
|
||||
row = _packet(conn, packet_id)
|
||||
if row["status"] not in BOOK_STATUSES:
|
||||
raise ApiError(409, "wrong_status", MSG_BOOK_STATUS)
|
||||
read = _read(conn, packet_id)
|
||||
name = prints_dom.databook_name(read)
|
||||
book = _filed_book(conn, packet_id) if row["status"] == "filed" else None
|
||||
if book is not None:
|
||||
data = _asset_bytes(request, book)
|
||||
pages = book["pages"]
|
||||
stamp, sig = None, None
|
||||
asset_id: Any = book["id"]
|
||||
else:
|
||||
from ..pdf import databook
|
||||
from ..pdf.style import PdfError
|
||||
|
||||
try:
|
||||
built = databook.build(conn, settings_of(request), packet_id, who=_who(p), now=now_utc(), mode="preview")
|
||||
except PdfError as e:
|
||||
raise pdf_failed(e) from None
|
||||
data, pages, asset_id = built.bytes, built.pages, None
|
||||
stamp, sig = stamp_of(built)
|
||||
prints_dom.record(
|
||||
conn,
|
||||
p,
|
||||
action="export",
|
||||
kind="databook-export",
|
||||
what="Data book" if book is not None else "Data book (not yet filed)",
|
||||
pages=pages,
|
||||
packet_id=packet_id,
|
||||
unit_id=row["unit_id"],
|
||||
asset_id=asset_id,
|
||||
stamp=stamp,
|
||||
stamp_sig=sig,
|
||||
)
|
||||
return file_response(data, name, download=bool(download))
|
||||
|
||||
|
||||
@router.get("/{packet_id}/pdf/traveler")
|
||||
def traveler_pdf(
|
||||
packet_id: int,
|
||||
request: Request,
|
||||
download: int = Query(0, ge=0, le=1),
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> Response:
|
||||
"""The job traveler with its work order QR codes, stamped as a controlled copy."""
|
||||
from ..pdf import traveler
|
||||
from ..pdf.style import PdfError
|
||||
|
||||
conn = d.conn
|
||||
row = _packet(conn, packet_id)
|
||||
try:
|
||||
built = traveler.build(conn, settings_of(request), packet_id, who=_who(p), now=now_utc())
|
||||
except PdfError as e:
|
||||
raise pdf_failed(e) from None
|
||||
stamp, sig = stamp_of(built)
|
||||
read = _read(conn, packet_id)
|
||||
prints_dom.record(
|
||||
conn,
|
||||
p,
|
||||
action="export",
|
||||
kind="traveler",
|
||||
what="Job traveler",
|
||||
pages=built.pages,
|
||||
packet_id=packet_id,
|
||||
unit_id=row["unit_id"],
|
||||
stamp=stamp,
|
||||
stamp_sig=sig,
|
||||
)
|
||||
return file_response(built.bytes, prints_dom.traveler_name(read), download=bool(download))
|
||||
|
||||
|
||||
@router.get("/{packet_id}/export/traceability.csv")
|
||||
def traceability_csv(
|
||||
packet_id: int,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> Response:
|
||||
"""The weld and heat traceability record as CSV (always a download)."""
|
||||
from ..pdf import tracecsv
|
||||
|
||||
conn = d.conn
|
||||
row = _packet(conn, packet_id)
|
||||
text, read = tracecsv.build(conn, settings_of(request), packet_id)
|
||||
prints_dom.record(
|
||||
conn,
|
||||
p,
|
||||
action="export",
|
||||
kind="csv",
|
||||
what="Traceability record (CSV)",
|
||||
pages=None,
|
||||
packet_id=packet_id,
|
||||
unit_id=row["unit_id"],
|
||||
)
|
||||
return file_response(text.encode("utf-8"), prints_dom.csv_name(read), download=True, media_type=CSV)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- prints
|
||||
|
||||
|
||||
def _doc_label(conn: Any, packet_id: int, unit_id: int, asset_id: uuid.UUID) -> str | None:
|
||||
"""The label of a document this packet may print, or None when the asset is not one of its documents."""
|
||||
for b in packets.rows(
|
||||
conn,
|
||||
"SELECT item, heat FROM bom_lines WHERE packet_id = %s AND mtr_asset_id = %s ORDER BY item",
|
||||
(packet_id, asset_id),
|
||||
):
|
||||
return " ".join(x for x in (f"MTR - item {b['item']}", b["heat"] or "") if x)
|
||||
r = packets.one(
|
||||
conn,
|
||||
"SELECT title FROM packet_docs WHERE packet_id = %s AND asset_id = %s ORDER BY id DESC LIMIT 1",
|
||||
(packet_id, asset_id),
|
||||
)
|
||||
if r is not None:
|
||||
return str(r["title"] or "Document")
|
||||
r = packets.one(
|
||||
conn,
|
||||
"SELECT d.type, d.title FROM qc_docs d JOIN units u ON u.id = %s"
|
||||
" WHERE d.asset_id = %s AND (d.unit_id = u.id OR d.project_id = u.project_id) ORDER BY d.id DESC LIMIT 1",
|
||||
(unit_id, asset_id),
|
||||
)
|
||||
if r is not None:
|
||||
return " - ".join(str(x) for x in (r["type"], r["title"]) if x)
|
||||
r = packets.one(
|
||||
conn,
|
||||
"SELECT id FROM packet_seals WHERE packet_id = %s AND databook_asset_id = %s LIMIT 1",
|
||||
(packet_id, asset_id),
|
||||
)
|
||||
if r is not None:
|
||||
return "Data book"
|
||||
return None
|
||||
|
||||
|
||||
def _check_sheet(read: Mapping[str, Any], sheet: int) -> None:
|
||||
if not any(int(pg["n"]) == sheet and pg.get("type") == "drawing" for pg in read.get("pages") or []):
|
||||
raise ApiError(404, "not_found", "No such drawing sheet.")
|
||||
|
||||
|
||||
@router.get("/{packet_id}/print/{kind}")
|
||||
def print_copy(
|
||||
packet_id: int,
|
||||
kind: str,
|
||||
request: Request,
|
||||
sheet: int | None = Query(None, ge=1, le=100_000),
|
||||
asset: uuid.UUID | None = Query(None), # noqa: B008
|
||||
download: int = Query(0, ge=0, le=1),
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> Response:
|
||||
"""A PRINTED COPY: the pages stamped with who printed them and when, and "verify revision before use"."""
|
||||
if kind not in PRINT_KINDS:
|
||||
raise ApiError(404, "not_found", "No such print.")
|
||||
conn = d.conn
|
||||
row = _packet(conn, packet_id)
|
||||
read = _read(conn, packet_id)
|
||||
if kind not in SHEET_KINDS:
|
||||
sheet = None
|
||||
what = WHAT.get(kind, "")
|
||||
asset_id: uuid.UUID | None = None
|
||||
if kind == "doc":
|
||||
label = _doc_label(conn, packet_id, int(row["unit_id"]), asset) if asset is not None else None
|
||||
if asset is None or label is None:
|
||||
raise ApiError(404, "not_found", "No such document on this packet.")
|
||||
what, asset_id = label, asset
|
||||
elif kind == "databook" and row["status"] not in BOOK_STATUSES:
|
||||
raise ApiError(409, "wrong_status", MSG_BOOK_STATUS)
|
||||
elif kind == "void" and not any((pg.get("void") is not None) for pg in read.get("pages") or []):
|
||||
raise ApiError(404, "nothing_to_print", MSG_NOTHING)
|
||||
if sheet is not None:
|
||||
_check_sheet(read, sheet)
|
||||
what = f"{what} (sheet {sheet})"
|
||||
if kind == "databook" and row["status"] == "filed":
|
||||
book = _filed_book(conn, packet_id)
|
||||
asset_id = None if book is None else book["id"]
|
||||
|
||||
from ..pdf import prints as print_pdf
|
||||
from ..pdf.style import NothingToPrint, PdfError
|
||||
|
||||
try:
|
||||
built = print_pdf.build_print(
|
||||
conn, settings_of(request), packet_id, kind, who=_who(p), now=now_utc(), sheet=sheet, asset_id=asset_id
|
||||
)
|
||||
except NothingToPrint:
|
||||
raise ApiError(404, "nothing_to_print", MSG_NOTHING) from None
|
||||
except PdfError as e:
|
||||
raise pdf_failed(e) from None
|
||||
what = str(getattr(built, "what", None) or what)
|
||||
stamp, sig = stamp_of(built)
|
||||
detail: dict[str, Any] = {}
|
||||
if sheet is not None:
|
||||
detail["sheet"] = sheet
|
||||
prints_dom.record(
|
||||
conn,
|
||||
p,
|
||||
action="print",
|
||||
kind=kind,
|
||||
what=what,
|
||||
pages=built.pages,
|
||||
packet_id=packet_id,
|
||||
unit_id=row["unit_id"],
|
||||
asset_id=asset_id,
|
||||
stamp=stamp,
|
||||
stamp_sig=sig,
|
||||
detail=detail,
|
||||
)
|
||||
return file_response(built.bytes, prints_dom.print_name(read, what), download=bool(download))
|
||||
|
||||
|
||||
@router.get("/{packet_id}/prints")
|
||||
def list_prints(
|
||||
packet_id: int,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""The packet's printed copies and exports, newest first (at most 200)."""
|
||||
_packet(d.conn, packet_id)
|
||||
return {"prints": prints_dom.list_prints(d.conn, packet_id)}
|
||||
Reference in New Issue
Block a user