Files
gepafin-rendicontazione-api/app/routers/verbale.py
BFLOWS 23a2b525a4 feat(verbale): export PDF verbale istruttoria via weasyprint
- Dockerfile: dipendenze sistema libpango/libgdk-pixbuf/libcairo/shared-mime-info +
  fonts-dejavu per rendering WeasyPrint su debian slim
- requirements: weasyprint==61.2 + pydyf==0.10.0 (vincolo compatibilita,
  weasyprint 62.x ha bug con pydyf 0.11 su stream.transform) + jinja2==3.1.3
- templates_jinja/verbale_istruttoria.html: layout A4 professionale con
  intestazione Gepafin, dati pratica, tabelle fatture raggruppate per categoria
  (dichiarato vs ammesso con motivazione rettifica), ULA, documenti,
  soccorsi istruttori, totali, checklist finale, note istruttore, blocco firma
- routers/verbale: endpoint /verbale.html (debug preview) e /verbale.pdf
  (weasyprint on-the-fly) — solo ruoli istruttore/superadmin
- main: include router verbale, version bump 0.3.0

Testato E2E: PDF 27KB generato su pratica UNDER_REVIEW, magic bytes PDF-1.7 OK.
2026-04-18 16:54:35 +02:00

246 lines
8.1 KiB
Python

"""
Endpoint generazione verbale di istruttoria in HTML/PDF via Jinja2 + weasyprint.
Solo ruoli istruttore/superadmin possono scaricare.
"""
from datetime import datetime, date
from uuid import UUID
from pathlib import Path
from collections import OrderedDict
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response, HTMLResponse
from jinja2 import Environment, FileSystemLoader, select_autoescape
from sqlalchemy.orm import Session
from sqlalchemy import text
from ..db import get_db
from ..auth import AuthUser, get_current_user
from ..models import RemissionPractice
from .practices import _compute_gate_check
router = APIRouter(prefix="/api/remission-practices/instructor", tags=["verbale"])
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates_jinja"
# ---------- Jinja env & filters ----------
def _euro(v):
if v is None:
return ""
try:
n = float(v)
except (TypeError, ValueError):
return ""
s = f"{n:,.2f}"
# IT locale: 1,234,567.89 → 1.234.567,89
s = s.replace(",", "X").replace(".", ",").replace("X", ".")
return f"{s}"
def _datefmt(v):
if v is None:
return ""
if isinstance(v, str):
try:
v = datetime.fromisoformat(v).date()
except ValueError:
return v
if hasattr(v, "strftime"):
return v.strftime("%d/%m/%Y")
return str(v)
def _datetimefmt(v):
if v is None:
return ""
if isinstance(v, str):
try:
v = datetime.fromisoformat(v)
except ValueError:
return v
if hasattr(v, "strftime"):
return v.strftime("%d/%m/%Y %H:%M")
return str(v)
_AMEND_STATUS = {
"AWAITING": "In attesa risposta",
"RESPONSE_RECEIVED": "Risposta ricevuta",
"CLOSED": "Chiuso",
"EXPIRED": "Scaduto",
"REJECTED": "Rifiutato",
}
def _amendstatus(s):
return _AMEND_STATUS.get(s, s or "")
_env = Environment(
loader=FileSystemLoader(str(TEMPLATES_DIR)),
autoescape=select_autoescape(["html", "xml"]),
trim_blocks=True, lstrip_blocks=True,
)
_env.filters["euro"] = _euro
_env.filters["datefmt"] = _datefmt
_env.filters["datetimefmt"] = _datetimefmt
_env.filters["amendstatus"] = _amendstatus
_CONTRACT_LABELS = {
"T_IND": "Tempo indeterminato",
"T_DET": "Tempo determinato",
"APPR": "Apprendistato",
"STAGE": "Tirocinio / Stage",
"COLL": "Collaborazione coordinata",
"ALTRO": "Altro",
}
def _is_instructor(user: AuthUser) -> bool:
return user.role in ("ROLE_PRE_INSTRUCTOR", "ROLE_INSTRUCTOR_MANAGER", "ROLE_SUPER_ADMIN")
def _build_context(db: Session, practice: RemissionPractice, user: AuthUser) -> dict:
"""Prepara tutto il contesto per il template."""
# Gate check + totali
gate_obj = _compute_gate_check(practice); gate = gate_obj.model_dump() if hasattr(gate_obj, "model_dump") else dict(gate_obj)
totals = gate.get("totals") or {}
# Schema sections
sections = practice.schema_snapshot.get("sections") or []
cat_section = next((s for s in sections if s.get("type") == "category_grid"), {}) or {}
categories = cat_section.get("categories") or []
categories_map = {c.get("code"): c.get("label") for c in categories}
cat_order = {c.get("code"): i for i, c in enumerate(categories)}
ula_section = next((s for s in sections if s.get("type") == "ula_block"), {}) or {}
ula_enabled = bool(ula_section.get("enabled"))
ula_threshold = float(ula_section.get("threshold") or 1)
docs_section = next((s for s in sections if s.get("type") == "document_checklist"), {}) or {}
docs_required_raw = docs_section.get("required_types") or []
docs_required = [
(r if isinstance(r, dict) else {"code": r, "label": r})
for r in docs_required_raw
]
docs_by_code = {d.doc_code: {
"filename": d.filename, "verification_status": d.verification_status,
"verification_notes": d.verification_notes,
} for d in practice.documents}
# Raggruppo fatture per categoria in ordine schema
sorted_invoices = sorted(
practice.invoices,
key=lambda i: (cat_order.get(i.category_code, 999), i.invoice_number or "")
)
invoices_by_cat = OrderedDict()
for c in categories:
invoices_by_cat[c.get("code")] = []
for inv in sorted_invoices:
invoices_by_cat.setdefault(inv.category_code, []).append(inv)
per_cat_declared = totals.get("per_category_declared") or {}
per_cat_verified = totals.get("per_category_verified") or {}
# ULA aggregati
ula_fte_decl = sum(float(e.fte_pct or 0) for e in practice.ula_employees)
ula_fte_verif = sum(
float((e.fte_pct_verified if e.fte_pct_verified is not None else e.fte_pct) or 0)
for e in practice.ula_employees
if e.verification_status in ("AMMESSA", "PARZIALE")
)
ula_ok = ula_fte_verif >= ula_threshold
# Anagrafica beneficiario (da gepafin_schema.company)
company_row = db.execute(text("""
SELECT company_name, vat_number
FROM gepafin_schema.company
WHERE id = :cid
"""), {"cid": practice.company_id}).mappings().first()
company = dict(company_row) if company_row else {}
# Istruttore
instructor_name = None
if practice.reviewed_by:
row = db.execute(text("""
SELECT first_name || ' ' || last_name AS name
FROM gepafin_schema.gepafin_user WHERE id = :uid
"""), {"uid": practice.reviewed_by}).scalar()
instructor_name = row
elif user.user_id:
row = db.execute(text("""
SELECT first_name || ' ' || last_name AS name
FROM gepafin_schema.gepafin_user WHERE id = :uid
"""), {"uid": user.user_id}).scalar()
instructor_name = row
return {
"practice": practice,
"totals": totals,
"categories_map": categories_map,
"invoices_by_cat": invoices_by_cat,
"per_cat_declared": per_cat_declared,
"per_cat_verified": per_cat_verified,
"ula_section_enabled": ula_enabled,
"ula_threshold": ula_threshold,
"ula_fte_decl": ula_fte_decl,
"ula_fte_verif": ula_fte_verif,
"ula_ok": ula_ok,
"docs_required": docs_required,
"docs_by_code": docs_by_code,
"amendments": practice.amendment_requests or [],
"contract_labels": _CONTRACT_LABELS,
"company": company,
"instructor_name": instructor_name,
"generated_at": datetime.now().strftime("%d/%m/%Y"),
}
def _render_html(db: Session, practice_id: UUID, user: AuthUser) -> tuple[RemissionPractice, str]:
practice = db.query(RemissionPractice).filter(RemissionPractice.id == practice_id).first()
if not practice:
raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata")
if not _is_instructor(user):
raise HTTPException(status_code=403, detail="Ruolo istruttore richiesto")
ctx = _build_context(db, practice, user)
tpl = _env.get_template("verbale_istruttoria.html")
html = tpl.render(**ctx)
return practice, html
@router.get("/{practice_id}/verbale.html", response_class=HTMLResponse)
def verbale_html(
practice_id: UUID,
db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user),
):
"""Rendering HTML del verbale — utile per debug e preview rapida."""
_, html = _render_html(db, practice_id, user)
return HTMLResponse(content=html)
@router.get("/{practice_id}/verbale.pdf")
def verbale_pdf(
practice_id: UUID,
db: Session = Depends(get_db),
user: AuthUser = Depends(get_current_user),
):
"""Generazione PDF via weasyprint."""
# Import locale per non rallentare il cold start se weasyprint manca
from weasyprint import HTML as WeasyHTML
practice, html = _render_html(db, practice_id, user)
pdf_bytes = WeasyHTML(string=html).write_pdf()
filename = f"verbale_istruttoria_pratica_{practice.application_id}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Cache-Control": "no-store",
},
)