- 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.
246 lines
8.1 KiB
Python
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",
|
|
},
|
|
)
|