347 lines
12 KiB
Python
347 lines
12 KiB
Python
"""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)}
|