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:
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user