Compare commits

...

3 Commits

7 changed files with 426 additions and 2 deletions
+74
View File
@@ -12,6 +12,7 @@ from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.task_result import TaskResult # #58: esiti dei task della sessione
from src.backend.models.orm.user import User
from src.backend.models.api.statistics import (
CapabilityData,
@@ -294,3 +295,76 @@ async def get_recipe_runs(
}
for r in runs
]
@router.get("/runs/{run_id}/task-results")
async def get_run_task_results(
run_id: int,
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> dict:
"""#58: esiti, note, campi e allegati dei task di una sessione di produzione.
Endpoint di statistica (require_metrologist): GET /api/task-results vuole
il ruolo MeasurementTec e non si puo' riusare qui.
"""
run = (await db.execute(
select(ProductionRun.id).where(ProductionRun.id == run_id)
)).scalar_one_or_none()
if run is None:
raise HTTPException(status_code=404, detail="Sessione non trovata")
# #58: una query per i risultati della run, una per i task, una per gli utenti.
results = (await db.execute(
select(TaskResult)
.where(TaskResult.production_run_id == run_id)
.order_by(TaskResult.created_at.asc(), TaskResult.id.asc())
)).scalars().all()
task_ids = {r.task_id for r in results}
tasks = {}
if task_ids:
tasks = {t.id: t for t in (await db.execute(
select(RecipeTask).where(RecipeTask.id.in_(task_ids))
)).scalars().all()}
user_ids = {r.user_id for r in results}
names = {}
if user_ids:
names = dict((await db.execute(
select(User.id, User.display_name).where(User.id.in_(user_ids))
)).all())
counts = {"ok": 0, "nok": 0, "done": 0}
out = []
for r in results:
counts[r.outcome] = counts.get(r.outcome, 0) + 1
task = tasks.get(r.task_id)
# #58: etichette dai campi del task; se il campo e' stato tolto vale la chiave.
labels = {
f.get("key"): f.get("label") or f.get("key")
for f in (task.fields or []) if isinstance(f, dict)
} if task else {}
out.append({
"id": r.id,
"task_id": r.task_id,
"task_title": task.title if task else None,
"task_type": task.task_type if task else None,
"outcome": r.outcome,
"note": r.note,
"created_at": r.created_at.isoformat() if r.created_at else None,
"user_name": names.get(r.user_id),
"values": [
{"key": k, "label": labels.get(k, k), "value": v}
for k, v in (r.values or {}).items()
],
"attachments": [
{
"key": a.get("key"),
"label": labels.get(a.get("key"), a.get("key")),
"name": a.get("name"),
"path": a.get("path"),
}
for a in (r.attachments or []) if isinstance(a, dict)
],
})
return {"run_id": run_id, "counts": counts, "results": out}
+113
View File
@@ -287,3 +287,116 @@ class TestRunFilter:
headers=auth_headers(measurement_tec_user),
params={"recipe_id": 1})
assert resp.status_code == 403
class TestRunTaskResults:
"""#58: GET /api/statistics/runs/{id}/task-results."""
FIELDS = [
{"key": "peso", "label": "Peso", "type": "number", "default": None, "attachment": False},
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
]
async def _setup(self, client, db_session, maker_user, tec_user):
from datetime import datetime
from sqlalchemy import select
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.task_result import TaskResult
recipe = await create_test_recipe(db_session, maker_user.id, code="TR58")
version = (await db_session.execute(
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
)).scalars().first()
db_session.add(Station(code="ST-TR58", name="S", active=True, created_by=maker_user.id))
await db_session.commit()
station = (await db_session.execute(select(Station))).scalars().first()
run = ProductionRun(
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
operator_id=tec_user.id, status="open", started_at=datetime(2026, 1, 1),
)
db_session.add(run)
await db_session.commit()
h = auth_headers(maker_user)
check = (await client.post(
f"/api/recipes/{recipe.id}/tasks", headers=h,
json={"title": "Controllo", "task_type": "check"},
)).json()
note = (await client.post(
f"/api/recipes/{recipe.id}/tasks", headers=h,
json={"title": "Nota", "task_type": "note", "fields": self.FIELDS},
)).json()
# Inseriti fuori ordine: l'API deve ordinare per created_at.
rows = [
TaskResult(task_id=note["id"], version_id=version.id, production_run_id=run.id,
user_id=tec_user.id, outcome="done", note="ok finito",
values={"peso": "12", "foto": "x.jpg"},
attachments=[{"key": "foto", "path": "task_results/a.jpg", "name": "a.jpg"}],
created_at=datetime(2026, 1, 1, 10, 3)),
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
user_id=tec_user.id, outcome="nok", note="graffio",
created_at=datetime(2026, 1, 1, 10, 2)),
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
user_id=tec_user.id, outcome="ok",
created_at=datetime(2026, 1, 1, 10, 1)),
# Un esito di un'altra sessione (nessuna) non deve comparire.
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=None,
user_id=tec_user.id, outcome="ok",
created_at=datetime(2026, 1, 1, 9, 0)),
]
db_session.add_all(rows)
await db_session.commit()
return recipe, run, check, note
async def test_counts_order_labels_attachments(
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
):
_, run, check, note = await self._setup(
client, db_session, maker_user, measurement_tec_user)
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
headers=auth_headers(metrologist_user))
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["run_id"] == run.id
assert data["counts"] == {"ok": 1, "nok": 1, "done": 1}
res = data["results"]
assert [r["outcome"] for r in res] == ["ok", "nok", "done"] # per ora crescente
assert res[1]["note"] == "graffio"
assert res[1]["task_title"] == "Controllo"
assert res[1]["task_type"] == "check"
assert res[1]["user_name"] == measurement_tec_user.display_name
assert res[1]["values"] == [] and res[1]["attachments"] == []
n = res[2]
assert n["task_title"] == "Nota" and n["note"] == "ok finito"
assert n["values"] == [
{"key": "peso", "label": "Peso", "value": "12"},
{"key": "foto", "label": "Foto", "value": "x.jpg"},
]
assert n["attachments"] == [
{"key": "foto", "label": "Foto", "name": "a.jpg", "path": "task_results/a.jpg"},
]
async def test_label_falls_back_to_key_when_field_removed(
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
):
from src.backend.models.orm.task import RecipeTask
_, run, _, note = await self._setup(
client, db_session, maker_user, measurement_tec_user)
task = await db_session.get(RecipeTask, note["id"])
task.fields = [self.FIELDS[0]] # il campo «foto» viene tolto dal task
await db_session.commit()
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
headers=auth_headers(metrologist_user))
n = resp.json()["results"][2]
assert {v["key"]: v["label"] for v in n["values"]} == {"peso": "Peso", "foto": "foto"}
assert n["attachments"][0]["label"] == "foto"
async def test_unknown_run_is_404(self, client, metrologist_user):
resp = await client.get("/api/statistics/runs/99999/task-results",
headers=auth_headers(metrologist_user))
assert resp.status_code == 404
async def test_measurement_tec_is_403(self, client, measurement_tec_user):
resp = await client.get("/api/statistics/runs/1/task-results",
headers=auth_headers(measurement_tec_user))
assert resp.status_code == 403
@@ -1,12 +1,13 @@
"""Metrologist blueprint - SPC statistics dashboard and API proxies."""
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 blueprints.auth import login_required, role_required
from config import Config
from services.api_client import api_client
from services.file_proxy import proxy_file # #58: allegati dei task
statistics_bp = Blueprint("statistics", __name__)
@@ -148,6 +149,33 @@ def api_runs():
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
# ============================================================================
@@ -129,6 +129,74 @@
</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 -->
<template x-if="!selectedRecipeId && !loading">
<div class="text-center py-16 text-steel">
@@ -290,8 +358,52 @@ function spcDashboard() {
reportError: '',
downloadingReport: false,
// #58: esiti dei task della sessione scelta
taskResults: null,
taskResultsLoading: false,
taskResultsError: '',
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() {
@@ -130,3 +130,40 @@ class TestRunsProxy:
mock_api_client.get.return_value = {"total": 1}
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"
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"
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"
#~ msgstr "Logout"
@@ -2981,6 +2981,36 @@ msgstr "Seleziona un punto di misura per l'istogramma"
msgid "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"
#~ msgstr "Esci"