feat: FASE 6.5 - Report PDF (WeasyPrint + Kaleido)

Add PDF report generation for Metrologist dashboard with SPC and
measurement reports including SVG charts, capability indices, and
company logo embedding.

New files:
- server/services/report_service.py (Jinja2 + Plotly/Kaleido + WeasyPrint)
- server/routers/reports.py (2 GET endpoints with auth)
- server/templates/reports/ (base, spc, measurement HTML templates)

Modified:
- server/main.py (register reports router)
- client dashboard (download buttons + proxy routes)
- i18n strings IT/EN

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adriano
2026-02-07 15:24:32 +01:00
parent bcd807e57d
commit 26e5b9343d
10 changed files with 1056 additions and 3 deletions
+72 -1
View File
@@ -1,8 +1,11 @@
"""Metrologist blueprint - SPC statistics dashboard and API proxies."""
from flask import Blueprint, jsonify, render_template, request
import requests as http_requests
from flask import Blueprint, jsonify, render_template, request, session
from flask_babel import gettext as _
from blueprints.auth import login_required, role_required
from config import Config
from services.api_client import api_client
statistics_bp = Blueprint("statistics", __name__)
@@ -105,3 +108,71 @@ def api_subtasks():
return jsonify(resp)
return jsonify(resp)
# ============================================================================
# PROXY REPORT PDF → FastAPI
# ============================================================================
def _proxy_report(endpoint: str):
"""Forward query params to a FastAPI reports endpoint and return PDF."""
params = {}
for key in [
"recipe_id", "version_id", "subtask_id",
"date_from", "date_to", "operator_id",
"lot_number", "serial_number",
]:
val = request.args.get(key)
if val is not None and val != "":
params[key] = val
base_url = Config.API_SERVER_URL.rstrip("/")
headers = {}
api_key = session.get("api_key")
if api_key:
headers["X-API-Key"] = api_key
try:
resp = http_requests.get(
f"{base_url}/api/reports/{endpoint}",
headers=headers,
params=params,
timeout=120,
)
except (http_requests.ConnectionError, http_requests.Timeout) as e:
return jsonify({"error": True, "detail": str(e)}), 502
if not resp.ok:
try:
err = resp.json()
detail = err.get("detail", f"HTTP {resp.status_code}")
except Exception:
detail = f"HTTP {resp.status_code}"
return jsonify({"error": True, "detail": detail}), resp.status_code
from flask import Response as FlaskResponse
return FlaskResponse(
resp.content,
mimetype="application/pdf",
headers={
"Content-Disposition": resp.headers.get(
"Content-Disposition", f'attachment; filename="report.pdf"'
)
},
)
@statistics_bp.route("/api/report-spc")
@login_required
@role_required("Metrologist")
def api_report_spc():
"""Proxy: download SPC PDF report."""
return _proxy_report("spc")
@statistics_bp.route("/api/report-measurements")
@login_required
@role_required("Metrologist")
def api_report_measurements():
"""Proxy: download measurements PDF report."""
return _proxy_report("measurements")
+73 -2
View File
@@ -61,8 +61,41 @@
</div>
</div>
<!-- Apply button -->
<div class="mt-4 flex justify-end">
<!-- Apply button + Download -->
<div class="mt-4 flex justify-between items-center">
<!-- Download buttons (visible only when data is loaded) -->
<div class="flex gap-2" x-show="hasData" x-cloak>
<button @click="downloadReport('spc')"
:disabled="!selectedSubtaskId || downloadingReport"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
<template x-if="downloadingReport === 'spc'">
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</template>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" x-show="downloadingReport !== 'spc'">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ _("Report SPC") }}
</button>
<button @click="downloadReport('measurements')"
:disabled="downloadingReport"
class="px-4 py-2 bg-[var(--bg-primary)] hover:bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
<template x-if="downloadingReport === 'measurements'">
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</template>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" x-show="downloadingReport !== 'measurements'">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ _("Report Misurazioni") }}
</button>
</div>
<div x-show="!hasData"></div>
<button @click="loadData()"
:disabled="!selectedRecipeId || loading"
class="px-5 py-2 bg-primary hover:bg-primary-dark text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
@@ -233,6 +266,7 @@ function spcDashboard() {
loading: false,
hasData: false,
errorMessage: '',
downloadingReport: false,
init() {
// Nothing to pre-load
@@ -368,6 +402,43 @@ function spcDashboard() {
if (value >= 1.0) return 'text-amber-600';
return 'text-red-600';
},
async downloadReport(type) {
this.downloadingReport = type;
try {
const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
const url = '/statistics/api/report-' + type + '?' + params.toString();
const resp = await fetch(url);
if (!resp.ok) {
const errData = await resp.json().catch(() => ({}));
this.errorMessage = errData.detail || '{{ _("Errore nella generazione del report") }}';
return;
}
// Download the PDF
const blob = await resp.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = type === 'spc'
? 'spc_report_' + this.selectedRecipeId + '.pdf'
: 'measurements_report_' + this.selectedRecipeId + '.pdf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
} catch (e) {
console.error('Error downloading report:', e);
this.errorMessage = '{{ _("Errore di connessione al server") }}';
} finally {
this.downloadingReport = false;
}
},
};
}
</script>
@@ -1038,3 +1038,13 @@ msgstr "Normal curve"
msgid "Indici non disponibili"
msgstr "Indices not available"
# Report PDF
msgid "Report SPC"
msgstr "SPC Report"
msgid "Report Misurazioni"
msgstr "Measurements Report"
msgid "Errore nella generazione del report"
msgstr "Error generating report"
@@ -1038,3 +1038,13 @@ msgstr "Curva normale"
msgid "Indici non disponibili"
msgstr "Indici non disponibili"
# Report PDF
msgid "Report SPC"
msgstr "Report SPC"
msgid "Report Misurazioni"
msgstr "Report Misurazioni"
msgid "Errore nella generazione del report"
msgstr "Errore nella generazione del report"