- core/printing.py: SumatraPDF silent print backend (dry_run, timeout, error capture) - core/print_document.py: orchestrate generate() + print, per-report paper size, printer fallback - core/print_api.py: FastAPI POST /api/print + GET /api/health, HTTPBearer auth, English Swagger at /api/docs - serve.py: uvicorn entrypoint - config: printers/print/api sections in settings.yaml + core/settings.py - requirements.txt: add fastapi, uvicorn - .gitignore: ignore logs/
233 lines
8.4 KiB
Python
233 lines
8.4 KiB
Python
"""
|
|
FastAPI print service for WareShipManifest.
|
|
|
|
All endpoints are mounted under the ``/api`` prefix:
|
|
POST /api/print generate a shipping document PDF and physically print it
|
|
GET /api/health liveness probe
|
|
|
|
Interactive Swagger UI is served at ``/api/docs`` and the OpenAPI schema at
|
|
``/api/openapi.json``. All documentation is in English.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Literal, Optional
|
|
|
|
from fastapi import APIRouter, Depends, FastAPI, HTTPException
|
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from core.settings import PROJECT_ROOT, settings
|
|
from core import print_document as pd
|
|
|
|
app = FastAPI(
|
|
title="WareShipManifest Print Service API",
|
|
description=(
|
|
"HTTP API for the WareShipManifest print service.\n\n"
|
|
"## Overview\n"
|
|
"Generate warehouse shipping documents (packing list / sign-off receipt) "
|
|
"as PDF and send them to a physical printer through SumatraPDF.\n\n"
|
|
"## Endpoints\n"
|
|
"* `POST /api/print` — generate a document and print it "
|
|
"(or preview with `dry_run`).\n"
|
|
"* `GET /api/health` — liveness probe.\n\n"
|
|
"## Authentication\n"
|
|
"When `api.token` is configured in `settings.yaml`, every request must "
|
|
"carry a bearer token. Click the **Authorize** button (top right of this "
|
|
"page), enter the token value, and it will be sent automatically as "
|
|
"`Authorization: Bearer <token>`. Requests without a valid token are "
|
|
"rejected with `401` (missing) or `403` (invalid).\n\n"
|
|
"## Notes\n"
|
|
"* `dry_run=true` generates the PDF and builds the print command but "
|
|
"does **not** send paper to the printer — useful for testing.\n"
|
|
"* The target printer falls back to `printers.default` in `settings.yaml` "
|
|
"when `printer` is omitted.\n"
|
|
"* Paper size and orientation are chosen automatically per report type: "
|
|
"packing_list → A5 landscape, sign_receipt → A4 portrait.\n"
|
|
"* A JSON line is appended to `logs/print.log` for every request."
|
|
),
|
|
version="1.0.0",
|
|
docs_url="/api/docs",
|
|
openapi_url="/api/openapi.json",
|
|
openapi_tags=[
|
|
{
|
|
"name": "print",
|
|
"description": "Generate shipping documents and send them to a printer.",
|
|
}
|
|
],
|
|
)
|
|
|
|
api_router = APIRouter(prefix="/api", tags=["print"])
|
|
|
|
security = HTTPBearer(auto_error=False)
|
|
|
|
|
|
class PrintRequest(BaseModel):
|
|
report_type: Literal["packing_list", "sign_receipt"] = Field(
|
|
...,
|
|
description="Type of document to generate and print.",
|
|
examples=["packing_list", "sign_receipt"],
|
|
)
|
|
params: dict[str, Any] = Field(
|
|
...,
|
|
description=(
|
|
"Report parameters. packing_list requires `paichan_no` (str) and "
|
|
"`box_no` (int); sign_receipt requires `receipt_no` (str, 11-digit)."
|
|
),
|
|
examples=[{"paichan_no": "R07425", "box_no": 1}],
|
|
)
|
|
printer: Optional[str] = Field(
|
|
None,
|
|
description="Target printer name. Falls back to `printers.default` in settings.yaml when omitted.",
|
|
examples=["Canon G1030 series (网络 USB1)"],
|
|
)
|
|
dry_run: bool = Field(
|
|
False,
|
|
description="When true, generate the PDF and build the print command but do NOT send it to the printer.",
|
|
examples=[True, False],
|
|
)
|
|
|
|
model_config = ConfigDict(
|
|
json_schema_extra={
|
|
"example": {
|
|
"report_type": "packing_list",
|
|
"params": {"paichan_no": "R07425", "box_no": 1},
|
|
"dry_run": False,
|
|
}
|
|
}
|
|
)
|
|
|
|
|
|
class PrintResponse(BaseModel):
|
|
ok: bool = Field(..., description="Whether the print job succeeded.")
|
|
report_type: str = Field(..., description="The report type that was requested.")
|
|
printer: Optional[str] = Field(None, description="The printer the job was sent to.")
|
|
pdf: str = Field(..., description="Absolute path of the generated PDF file.")
|
|
dry_run: bool = Field(
|
|
..., description="True when the PDF was generated but not physically printed."
|
|
)
|
|
returncode: Optional[int] = Field(
|
|
None, description="Exit code of the SumatraPDF print process (null in dry_run)."
|
|
)
|
|
error: Optional[str] = Field(None, description="Error message, if any.")
|
|
|
|
|
|
class PrintError(BaseModel):
|
|
detail: str = Field(
|
|
..., description="Human-readable error message describing what went wrong."
|
|
)
|
|
|
|
|
|
def _verify_token(
|
|
creds: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
|
) -> None:
|
|
"""Skip auth when no token is configured (local debugging); otherwise verify Bearer."""
|
|
if not settings.api.token:
|
|
return
|
|
if creds is None:
|
|
raise HTTPException(status_code=401, detail="Missing bearer token")
|
|
if creds.credentials != settings.api.token:
|
|
raise HTTPException(status_code=403, detail="Invalid token")
|
|
|
|
|
|
def _log(result: dict) -> None:
|
|
log_path = PROJECT_ROOT / "logs" / "print.log"
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
entry = {**result, "ts": datetime.now().isoformat()}
|
|
with open(log_path, "a", encoding="utf-8") as f:
|
|
f.write(json.dumps(entry, ensure_ascii=False, default=str) + "\n")
|
|
|
|
|
|
@api_router.post(
|
|
"/print",
|
|
summary="Generate and print a shipping document",
|
|
description=(
|
|
"Generate the requested document as a PDF using the existing report "
|
|
"pipeline, then send the PDF to the target printer via SumatraPDF in "
|
|
"silent mode.\n\n"
|
|
"**Flow**\n"
|
|
"1. Validate the request and the required parameters.\n"
|
|
"2. Generate the PDF from the database.\n"
|
|
"3. Build and execute the SumatraPDF print command "
|
|
"(skipped when `dry_run=true`).\n"
|
|
"4. Append a JSON line to `logs/print.log`.\n"
|
|
"5. Return the job result.\n\n"
|
|
"Set `dry_run=true` to generate the PDF without consuming paper."
|
|
),
|
|
response_description="Print job result, including the generated PDF path.",
|
|
response_model=PrintResponse,
|
|
responses={
|
|
400: {
|
|
"description": "Invalid or missing parameters (e.g. required field not provided).",
|
|
"model": PrintError,
|
|
},
|
|
401: {"description": "Missing bearer token (auth enabled).", "model": PrintError},
|
|
403: {"description": "Invalid bearer token.", "model": PrintError},
|
|
404: {
|
|
"description": "Referenced record not found (e.g. receipt_no / box does not exist).",
|
|
"model": PrintError,
|
|
},
|
|
422: {
|
|
"description": "Report generation or printing failed at runtime.",
|
|
"model": PrintError,
|
|
},
|
|
500: {"description": "Unexpected server error.", "model": PrintError},
|
|
},
|
|
dependencies=[Depends(_verify_token)],
|
|
)
|
|
def print_endpoint(req: PrintRequest):
|
|
try:
|
|
result = pd.print_document(
|
|
report_type=req.report_type,
|
|
params=req.params,
|
|
printer=req.printer,
|
|
dry_run=req.dry_run,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except FileNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
except RuntimeError as e:
|
|
raise HTTPException(status_code=422, detail=str(e))
|
|
except Exception as e: # noqa: BLE001
|
|
raise HTTPException(status_code=500, detail=f"print failed: {e}")
|
|
|
|
_log(result)
|
|
return {
|
|
"ok": result.get("ok", False),
|
|
"report_type": req.report_type,
|
|
"printer": result.get("printer"),
|
|
"pdf": str(result.get("pdf")),
|
|
"dry_run": result.get("dry_run", False),
|
|
"returncode": result.get("returncode"),
|
|
"error": result.get("error"),
|
|
}
|
|
|
|
|
|
@api_router.get(
|
|
"/health",
|
|
summary="Liveness probe",
|
|
description=(
|
|
"Returns `{\"status\": \"ok\"}` when the service is running. "
|
|
"No authentication is required. Use it for health checks / uptime monitoring."
|
|
),
|
|
response_description="Service status.",
|
|
responses={
|
|
200: {
|
|
"description": "Service is alive.",
|
|
"content": {
|
|
"application/json": {
|
|
"example": {"status": "ok"},
|
|
}
|
|
},
|
|
}
|
|
},
|
|
)
|
|
def health():
|
|
return {"status": "ok"}
|
|
|
|
|
|
app.include_router(api_router)
|