diff --git a/src/backend/api/routers/statistics.py b/src/backend/api/routers/statistics.py index 1f8321d..da67f0a 100644 --- a/src/backend/api/routers/statistics.py +++ b/src/backend/api/routers/statistics.py @@ -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} diff --git a/src/backend/tests/test_statistics.py b/src/backend/tests/test_statistics.py index e9e41ad..ae34236 100644 --- a/src/backend/tests/test_statistics.py +++ b/src/backend/tests/test_statistics.py @@ -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 diff --git a/src/frontend/flask_app/blueprints/statistics.py b/src/frontend/flask_app/blueprints/statistics.py index 178716a..e2611d0 100644 --- a/src/frontend/flask_app/blueprints/statistics.py +++ b/src/frontend/flask_app/blueprints/statistics.py @@ -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//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/") +@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 # ============================================================================ diff --git a/src/frontend/flask_app/templates/statistics/dashboard.html b/src/frontend/flask_app/templates/statistics/dashboard.html index 4b4400c..d08d32f 100644 --- a/src/frontend/flask_app/templates/statistics/dashboard.html +++ b/src/frontend/flask_app/templates/statistics/dashboard.html @@ -129,6 +129,74 @@ + +
+

{{ _("Esiti dei task della sessione") }}

+

{{ _("Caricamento...") }}

+

+ +
+