Statistica: endpoint esiti, note, campi e allegati dei task di una sessione (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
This commit is contained in:
@@ -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.production import ProductionRun
|
||||||
from src.backend.models.orm.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
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.orm.user import User
|
||||||
from src.backend.models.api.statistics import (
|
from src.backend.models.api.statistics import (
|
||||||
CapabilityData,
|
CapabilityData,
|
||||||
@@ -294,3 +295,76 @@ async def get_recipe_runs(
|
|||||||
}
|
}
|
||||||
for r in 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}
|
||||||
|
|||||||
@@ -287,3 +287,116 @@ class TestRunFilter:
|
|||||||
headers=auth_headers(measurement_tec_user),
|
headers=auth_headers(measurement_tec_user),
|
||||||
params={"recipe_id": 1})
|
params={"recipe_id": 1})
|
||||||
assert resp.status_code == 403
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user