Statistica: pannello esiti, note, campi e allegati dei task per sessione, proxy e traduzioni (#58)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
This commit is contained in:
Adriano
2026-10-11 18:08:18 +00:00
parent c911449b7c
commit 8d7fa9a12a
5 changed files with 239 additions and 2 deletions
@@ -1,12 +1,13 @@
"""Metrologist blueprint - SPC statistics dashboard and API proxies.""" """Metrologist blueprint - SPC statistics dashboard and API proxies."""
import requests as http_requests import requests as http_requests
from flask import Blueprint, jsonify, render_template, request, session from flask import Blueprint, abort, jsonify, render_template, request, session
from flask_babel import gettext as _ from flask_babel import gettext as _
from blueprints.auth import login_required, role_required from blueprints.auth import login_required, role_required
from config import Config from config import Config
from services.api_client import api_client from services.api_client import api_client
from services.file_proxy import proxy_file # #58: allegati dei task
statistics_bp = Blueprint("statistics", __name__) statistics_bp = Blueprint("statistics", __name__)
@@ -148,6 +149,33 @@ def api_runs():
return jsonify(resp) return jsonify(resp)
@statistics_bp.route("/api/runs/<int:run_id>/task-results")
@login_required
@role_required("Metrologist")
def api_run_task_results(run_id: int):
"""Proxy: esiti, note, campi e allegati dei task di una sessione (#58)."""
resp = api_client.get(f"/api/statistics/runs/{run_id}/task-results")
if isinstance(resp, dict) and resp.get("error"):
error_body = {"error": True, "detail": resp.get("detail", "")}
if resp.get("session_expired"):
error_body["session_expired"] = True
return jsonify(error_body), resp.get("status_code", 500)
return jsonify(resp)
@statistics_bp.route("/api/files/<path:file_path>")
@login_required
@role_required("Metrologist")
def api_task_result_file(file_path: str):
"""Proxy: allegati dei task (#58). Solo task_results/, mai altri file."""
# #58: il Metrologist non deve poter leggere altro dell'upload dir (es. statistics/).
if not file_path.startswith("task_results/") or ".." in file_path.split("/"):
abort(404)
return proxy_file(file_path)
# ============================================================================ # ============================================================================
# PROXY REPORT PDF → FastAPI # PROXY REPORT PDF → FastAPI
# ============================================================================ # ============================================================================
@@ -129,6 +129,74 @@
</div> </div>
</div> </div>
<!-- #58: esiti, note, campi e allegati dei task della sessione scelta -->
<div id="run-task-results" x-show="selectedRunId" x-cloak
class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 mb-6 shadow-sm">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Esiti dei task della sessione") }}</h2>
<p x-show="taskResultsLoading" class="text-sm text-steel">{{ _("Caricamento...") }}</p>
<p x-show="taskResultsError" x-text="taskResultsError" class="text-sm text-red-600"></p>
<template x-if="taskResults && !taskResultsLoading">
<div>
<div class="grid grid-cols-3 gap-3 mb-4 max-w-md">
<div class="text-center p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20">
<div class="text-2xl font-bold font-mono text-emerald-600" x-text="taskResults.counts.ok"></div>
<div class="text-xs text-emerald-700 dark:text-emerald-400 mt-1">{{ _("OK") }}</div>
</div>
<div class="text-center p-3 rounded-lg bg-red-50 dark:bg-red-900/20">
<div class="text-2xl font-bold font-mono text-red-600" x-text="taskResults.counts.nok"></div>
<div class="text-xs text-red-700 dark:text-red-400 mt-1">{{ _("NOK") }}</div>
</div>
<div class="text-center p-3 rounded-lg bg-[var(--bg-primary)]">
<div class="text-2xl font-bold font-mono text-[var(--text-primary)]" x-text="taskResults.counts.done"></div>
<div class="text-xs text-steel mt-1">{{ _("Fatto") }}</div>
</div>
</div>
<p x-show="taskResults.results.length === 0" class="text-sm text-steel">{{ _("Nessun esito registrato in questa sessione") }}</p>
<div x-show="taskResults.results.length > 0" class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs text-steel uppercase border-b border-[var(--border-color)]">
<th class="py-2 pr-3">{{ _("Ora") }}</th>
<th class="py-2 pr-3">{{ _("Task") }}</th>
<th class="py-2 pr-3">{{ _("Esito") }}</th>
<th class="py-2 pr-3">{{ _("Note") }}</th>
<th class="py-2 pr-3">{{ _("Campi") }}</th>
<th class="py-2 pr-3">{{ _("Allegati") }}</th>
<th class="py-2">{{ _("Operatore") }}</th>
</tr>
</thead>
<tbody>
<template x-for="r in taskResults.results" :key="r.id">
<tr class="border-b border-[var(--border-color)] align-top">
<td class="py-2 pr-3 font-mono whitespace-nowrap" x-text="resultTime(r.created_at)"></td>
<td class="py-2 pr-3" x-text="r.task_title"></td>
<td class="py-2 pr-3">
<span class="px-2 py-0.5 rounded text-xs font-semibold"
:class="r.outcome === 'nok' ? 'bg-red-600 text-white' : (r.outcome === 'ok' ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-200 text-gray-700')"
x-text="outcomeLabel(r.outcome)"></span>
</td>
<td class="py-2 pr-3 whitespace-pre-line" x-text="r.note"></td>
<td class="py-2 pr-3">
<template x-for="v in r.values" :key="v.key">
<div><span class="text-steel" x-text="v.label + ': '"></span><span x-text="v.value"></span></div>
</template>
</td>
<td class="py-2 pr-3">
<template x-for="a in r.attachments" :key="a.path">
<div><a :href="attachmentUrl(a.path)" target="_blank" rel="noopener"
class="text-primary hover:underline" x-text="a.name || a.label"></a></div>
</template>
</td>
<td class="py-2" x-text="r.user_name"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
</div>
<!-- Nessuna ricetta selezionata --> <!-- Nessuna ricetta selezionata -->
<template x-if="!selectedRecipeId && !loading"> <template x-if="!selectedRecipeId && !loading">
<div class="text-center py-16 text-steel"> <div class="text-center py-16 text-steel">
@@ -290,8 +358,52 @@ function spcDashboard() {
reportError: '', reportError: '',
downloadingReport: false, downloadingReport: false,
// #58: esiti dei task della sessione scelta
taskResults: null,
taskResultsLoading: false,
taskResultsError: '',
init() { init() {
// Nothing to pre-load // #58: il pannello esiti si ricarica a ogni cambio di sessione
this.$watch('selectedRunId', () => this.loadTaskResults());
},
async loadTaskResults() {
this.taskResults = null;
this.taskResultsError = '';
const runId = this.selectedRunId;
if (!runId) return;
this.taskResultsLoading = true;
try {
const resp = await fetch('/statistics/api/runs/' + runId + '/task-results');
const data = await resp.json();
// #58: se nel frattempo la sessione e' cambiata, il risultato e' vecchio
if (runId !== this.selectedRunId) return;
if (!resp.ok || data.error) {
this.taskResultsError = data.detail || {{ _("Errore nel caricamento dei dati")|tojson }};
} else {
this.taskResults = data;
}
} catch (e) {
console.error('Error loading task results:', e);
this.taskResultsError = {{ _("Errore di connessione al server")|tojson }};
} finally {
if (runId === this.selectedRunId) this.taskResultsLoading = false;
}
},
// #58: ora locale dell'esito (created_at e' UTC senza suffisso)
resultTime(iso) {
return iso ? new Date(iso + (iso.endsWith('Z') ? '' : 'Z')).toLocaleString() : '';
},
outcomeLabel(o) {
return o === 'ok' ? {{ _("OK")|tojson }} : (o === 'nok' ? {{ _("NOK")|tojson }} : {{ _("Fatto")|tojson }});
},
// #58: gli allegati passano dal proxy del blueprint, il browser non manda X-API-Key
attachmentUrl(path) {
return '/statistics/api/files/' + String(path).split('/').map(encodeURIComponent).join('/');
}, },
async onRecipeChange() { async onRecipeChange() {
@@ -130,3 +130,40 @@ class TestRunsProxy:
mock_api_client.get.return_value = {"total": 1} mock_api_client.get.return_value = {"total": 1}
logged_in_client.get("/statistics/api/summary?recipe_id=1&production_run_id=5") logged_in_client.get("/statistics/api/summary?recipe_id=1&production_run_id=5")
assert mock_api_client.get.call_args.kwargs["params"]["production_run_id"] == "5" assert mock_api_client.get.call_args.kwargs["params"]["production_run_id"] == "5"
class TestRunTaskResultsProxy:
"""#58: esiti dei task e allegati della sessione."""
def test_task_results_proxy(self, logged_in_client, mock_api_client):
payload = {"run_id": 5, "counts": {"ok": 1, "nok": 0, "done": 0}, "results": []}
mock_api_client.get.return_value = payload
resp = logged_in_client.get("/statistics/api/runs/5/task-results")
assert resp.status_code == 200
assert resp.get_json() == payload
assert mock_api_client.get.call_args.args[0] == "/api/statistics/runs/5/task-results"
def test_task_results_proxy_relays_error(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {"error": True, "detail": "x", "status_code": 404}
resp = logged_in_client.get("/statistics/api/runs/9/task-results")
assert resp.status_code == 404
assert resp.get_json()["error"] is True
def test_file_proxy_serves_task_results_only(self, logged_in_client):
from unittest.mock import patch
from flask import Response
with patch("blueprints.statistics.proxy_file",
return_value=Response("img", status=200)) as pf:
ok = logged_in_client.get("/statistics/api/files/task_results/a.jpg")
assert ok.status_code == 200
pf.assert_called_once_with("task_results/a.jpg")
for bad in ("statistics/x", "task_results/../statistics/x", "../etc/passwd"):
assert logged_in_client.get(f"/statistics/api/files/{bad}").status_code == 404
assert pf.call_count == 1 # i percorsi rifiutati non arrivano al proxy
def test_dashboard_has_task_results_panel(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1}
html = logged_in_client.get("/statistics/dashboard").get_data(as_text=True)
assert 'id="run-task-results"' in html
assert "loadTaskResults" in html
assert "/statistics/api/runs/" in html
@@ -2977,6 +2977,36 @@ msgstr "Select a measurement point for the histogram"
msgid "Errore nella generazione del report" msgid "Errore nella generazione del report"
msgstr "Error generating report" msgstr "Error generating report"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Esiti dei task della sessione"
msgstr "Task outcomes of the session"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Nessun esito registrato in questa sessione"
msgstr "No outcome recorded in this session"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Fatto"
msgstr "Done"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Ora"
msgstr "Time"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Campi"
msgstr "Fields"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Allegati"
msgstr "Attachments"
#~ msgid "Esci" #~ msgid "Esci"
#~ msgstr "Logout" #~ msgstr "Logout"
@@ -2981,6 +2981,36 @@ msgstr "Seleziona un punto di misura per l'istogramma"
msgid "Errore nella generazione del report" msgid "Errore nella generazione del report"
msgstr "Errore nella generazione del report" msgstr "Errore nella generazione del report"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Esiti dei task della sessione"
msgstr "Esiti dei task della sessione"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Nessun esito registrato in questa sessione"
msgstr "Nessun esito registrato in questa sessione"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Fatto"
msgstr "Fatto"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Ora"
msgstr "Ora"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Campi"
msgstr "Campi"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Allegati"
msgstr "Allegati"
#~ msgid "Esci" #~ msgid "Esci"
#~ msgstr "Esci" #~ msgstr "Esci"