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:
@@ -6,10 +6,11 @@ answer is the full ITP read. ITP writes never look at a packet: a filed packet i
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
from psycopg.types.json import Jsonb
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -17,12 +18,16 @@ from .. import policy
|
||||
from ..domain import itp as dom
|
||||
from ..domain import ncr as ncrs
|
||||
from ..domain import packets
|
||||
from ..domain import prints as prints_dom
|
||||
from ..domain.itp_template import ITP_TEMPLATE
|
||||
from ..domain.pipeline import SignBody, apply_field_cas, jsonable, parse_if_match
|
||||
from ..text import LIMITS, clean_opt, clean_text
|
||||
from .deps import Db, Principal, current_principal, get_db
|
||||
from .deps import Db, Principal, current_principal, get_db, settings_of
|
||||
from .errors import ApiError
|
||||
from .packets import WRITE
|
||||
from .prints import file_response, now_utc, pdf_failed, stamp_of
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["itp"])
|
||||
|
||||
@@ -658,8 +663,10 @@ def sign_off(
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Sign the ITP off: every block signed. The ITP locks; the PDF hook (phase 7) may file a copy."""
|
||||
"""Sign the ITP off: every block signed. The ITP locks and the signed PDF is filed in the unit's Q.C. folder.
|
||||
A PDF that cannot be built answers 500 itp_pdf_failed and nothing is signed off."""
|
||||
from ..pdf import itp as itp_pdf
|
||||
from ..pdf.style import PdfError
|
||||
|
||||
conn = d.conn
|
||||
row0 = dom.load_row(conn, itp_id)
|
||||
@@ -676,12 +683,19 @@ def sign_off(
|
||||
)
|
||||
row = dom.load_row(conn, itp_id)
|
||||
assert row is not None
|
||||
asset_id = itp_pdf.file_signed_itp_pdf(conn, row, p.actor)
|
||||
try:
|
||||
asset_id = itp_pdf.file_signed_itp_pdf(conn, row, p.actor, settings=settings_of(request))
|
||||
except PdfError as e:
|
||||
log.warning("signed ITP PDF for ITP %s failed: %s", itp_id, e)
|
||||
text = " ".join(str(e).split()).rstrip(".")[:300] or "an unknown error"
|
||||
raise ApiError(
|
||||
500, "itp_pdf_failed", f"The signed ITP PDF could not be built: {text}. The ITP was not signed off."
|
||||
) from None
|
||||
if asset_id is not None:
|
||||
_file_auto_doc(w, row, asset_id)
|
||||
w.log("ITP signed off", "PDF filed")
|
||||
else:
|
||||
w.log("ITP signed off", "PDF filing reserved for phase 7")
|
||||
w.log("ITP signed off")
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
@@ -742,3 +756,82 @@ def reopen(
|
||||
)
|
||||
w.log("ITP reopened", reason)
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the ITP PDF and its printed copy
|
||||
|
||||
|
||||
def _itp_row(d: Db, itp_id: int) -> dict[str, Any]:
|
||||
row = dom.load_row(d.conn, itp_id)
|
||||
if row is None:
|
||||
raise ApiError(404, "not_found", dom.MSG_NO_ITP)
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/api/itps/{itp_id}/pdf")
|
||||
def itp_pdf_file(
|
||||
itp_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 ITP PDF built now, open or complete; the stamp says which. Logged as an export."""
|
||||
from ..pdf import itp as itp_pdf
|
||||
from ..pdf.style import PdfError
|
||||
|
||||
conn = d.conn
|
||||
row = _itp_row(d, itp_id)
|
||||
try:
|
||||
built = itp_pdf.build(conn, settings_of(request), row, who=prints_dom.who_of(p), now=now_utc())
|
||||
except PdfError as e:
|
||||
raise pdf_failed(e) from None
|
||||
stamp, sig = stamp_of(built)
|
||||
prints_dom.record(
|
||||
conn,
|
||||
p,
|
||||
action="export",
|
||||
kind="itp-pdf",
|
||||
what=f"ITP {row['no'] or ''}".strip(),
|
||||
pages=built.pages,
|
||||
unit_id=row["unit_id"],
|
||||
itp_id=itp_id,
|
||||
stamp=stamp,
|
||||
stamp_sig=sig,
|
||||
)
|
||||
return file_response(built.bytes, prints_dom.itp_name(row["no"]), download=bool(download))
|
||||
|
||||
|
||||
@router.get("/api/itps/{itp_id}/print")
|
||||
def itp_print(
|
||||
itp_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 ITP PDF stamped PRINTED COPY. Logged as a print."""
|
||||
from ..pdf import prints as print_pdf
|
||||
from ..pdf.style import PdfError
|
||||
|
||||
conn = d.conn
|
||||
row = _itp_row(d, itp_id)
|
||||
try:
|
||||
built = print_pdf.print_itp(conn, settings_of(request), row, who=prints_dom.who_of(p), now=now_utc())
|
||||
except PdfError as e:
|
||||
raise pdf_failed(e) from None
|
||||
stamp, sig = stamp_of(built)
|
||||
what = str(getattr(built, "what", None) or f"ITP {row['no'] or ''}".strip())
|
||||
prints_dom.record(
|
||||
conn,
|
||||
p,
|
||||
action="print",
|
||||
kind="itp",
|
||||
what=what,
|
||||
pages=built.pages,
|
||||
unit_id=row["unit_id"],
|
||||
itp_id=itp_id,
|
||||
stamp=stamp,
|
||||
stamp_sig=sig,
|
||||
)
|
||||
return file_response(built.bytes, prints_dom.itp_name(row["no"]), download=bool(download))
|
||||
|
||||
@@ -113,6 +113,7 @@ class TransitionBody(BaseModel):
|
||||
location: str | None = Field(default=None, max_length=500)
|
||||
for_sup_id: uuid.UUID | None = None
|
||||
ncr: str | None = Field(default=None, max_length=100)
|
||||
confirm_open_wos: bool | None = None
|
||||
sign: SignBody | None = None
|
||||
it_reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
@@ -501,7 +502,7 @@ TRANSITION_FIELDS: dict[policy.Action, tuple[str, ...]] = {
|
||||
A.QC_REJECT: ("reason", "ncr"),
|
||||
A.QC_ACCEPT: ("routed_to", "note"),
|
||||
A.QC_ACCEPT_AND_ROUTE: ("dept", "note"),
|
||||
A.FILE: ("note",),
|
||||
A.FILE: ("note", "confirm_open_wos"),
|
||||
A.ADMIN_REOPEN: ("reason",),
|
||||
}
|
||||
DEPT_ACTIONS = (A.RELEASE_TO_SHOP, A.ROUTE_FROM_REVIEW, A.QC_ACCEPT_AND_ROUTE)
|
||||
@@ -918,7 +919,9 @@ def set_vclosed(
|
||||
"SELECT DISTINCT wo_base FROM packet_work_orders WHERE packet_id = %s ORDER BY 1", (packet_id,)
|
||||
)
|
||||
]
|
||||
w.note = ", ".join(wos) or None
|
||||
still_open = packets.packet_open_wos(d.conn, w.packet)
|
||||
suffix = "VISUAL still shows open: " + ", ".join(still_open) if still_open else None
|
||||
w.note = " · ".join(x for x in (", ".join(wos), suffix) if x) or None
|
||||
else:
|
||||
d.conn.execute("UPDATE packets SET vclosed_at = NULL, vclosed_by = NULL WHERE id = %s", (packet_id,))
|
||||
w.changed("vclosed")
|
||||
|
||||
@@ -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