Initial commit: PESCO NCR system
Complete Non-Conformance Report system replacing the PowerApps/SharePoint prototype: FastAPI + SQLAlchemy 2 (async) + Alembic + MySQL 8 backend, React 18 + Vite + TypeScript + MUI frontend, Entra ID auth (MSAL / JWKS, group-gated), Microsoft Graph delegated Mail.Send notifications (OBO), six-stage workflow state machine with server-side enforcement, atomic NCR-YYYY-NNNN numbering, attachments with camera capture, immutable field-level audit trail, admin reopen, reports + CSV export, WeasyPrint PDF traveler, Power BI reporting views + read-only DB user, documented VISUAL ERP job-lookup stub, pytest suite (26 tests), docker-compose deployment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
64
backend/app/services/pdf.py
Normal file
64
backend/app/services/pdf.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Printable NCR PDF (WeasyPrint) — a clean single-document rendering of the
|
||||
complete NCR for hard-copy travelers and audits.
|
||||
|
||||
WeasyPrint is imported lazily so environments without the Pango/Cairo system
|
||||
libraries (e.g. unit tests) can still import the app.
|
||||
"""
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from app.domain import STAGE_LABELS, Stage
|
||||
from app.models import Ncr
|
||||
from app.models.base import utcnow
|
||||
from app.services.storage import attachment_abs_path
|
||||
|
||||
_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
|
||||
|
||||
_env = Environment(
|
||||
loader=FileSystemLoader(_TEMPLATES_DIR),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
)
|
||||
|
||||
|
||||
def _render_html(ncr: Ncr) -> str:
|
||||
images = []
|
||||
other_files = []
|
||||
for att in ncr.attachments:
|
||||
entry = {
|
||||
"filename": att.original_filename,
|
||||
"uploaded_by": att.uploaded_by.display_name,
|
||||
"uploaded_at": att.uploaded_at,
|
||||
"size_kb": max(1, att.size_bytes // 1024),
|
||||
}
|
||||
path = attachment_abs_path(att.stored_path)
|
||||
if att.is_image and path.is_file():
|
||||
entry["src"] = path.as_uri()
|
||||
images.append(entry)
|
||||
else:
|
||||
other_files.append(entry)
|
||||
|
||||
template = _env.get_template("ncr_pdf.html")
|
||||
return template.render(
|
||||
ncr=ncr,
|
||||
stage_label=STAGE_LABELS[Stage(ncr.stage)],
|
||||
stage_labels=STAGE_LABELS,
|
||||
Stage=Stage,
|
||||
images=images,
|
||||
other_files=other_files,
|
||||
generated_at=utcnow(),
|
||||
)
|
||||
|
||||
|
||||
def _html_to_pdf(html: str) -> bytes:
|
||||
from weasyprint import HTML # lazy: needs Pango/Cairo system libs
|
||||
|
||||
return HTML(string=html).write_pdf()
|
||||
|
||||
|
||||
async def render_ncr_pdf(ncr: Ncr) -> bytes:
|
||||
html = _render_html(ncr)
|
||||
# WeasyPrint rendering is CPU-bound; keep it off the event loop.
|
||||
return await anyio.to_thread.run_sync(partial(_html_to_pdf, html))
|
||||
Reference in New Issue
Block a user