Files
PacketStation/server/packetstation/api/prints.py
T

347 lines
12 KiB
Python
Raw Normal View History

2026-10-17 23:49:14 -06:00
"""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)}