feat(measure): proxy JSON per il progresso di un task (#37.8, parziale)

GET /measure/api/task-progress/<task_id>: il browser non puo' chiamare
/api/measurements/task-progress del backend (niente X-API-Key). Login +
stesso controllo di stazione di task_execute() (_recipe_allowed_at_station),
poi inoltra a _task_progress() con lo stesso version_id/production_run_id
gia' usato da task_list.html.

Non tocca task_execute.html: /api/measurements/task-progress risponde
con conteggi aggregati per task (quotes/measured/state), non con le
quote-per-quota misurate (subtask_id, value, pass_fail, deviation) che
`this.measurements` richiede - ripristinarlo avrebbe richiesto inventare
valori o uno stato non offerto dal backend. MANCA: mostrato in dettaglio
nel rapporto finale.

Test Flask della route: riga per il task richiesto, default a "empty"
se assente, 403 fuori stazione, propagazione 404, login richiesto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 23:36:42 +02:00
parent b27d56d4f7
commit cc686129c0
2 changed files with 113 additions and 0 deletions
@@ -565,6 +565,38 @@ def save_measurement():
return jsonify(resp), 201 return jsonify(resp), 201
# ---------------------------------------------------------------------------
# Route: task progress (#37.8) - browser cannot call the backend directly
# (X-API-Key), same as every other /api/* proxy in this file.
# ---------------------------------------------------------------------------
@measure_bp.route("/api/task-progress/<int:task_id>", methods=["GET"])
@login_required
@role_required("MeasurementTec")
def api_task_progress(task_id: int):
"""Proxy: how many quotes of this task are already measured.
Reuses _task_progress() - same version_id/production_run_id scoping as
task_list.html's badges - so this answers with exactly the counts
(quotes/measured/state) the backend can give without conflating one
cycle with another; it does not, and cannot, say which quotes or with
what values (task-progress is an aggregate, not a per-subtask list).
"""
task_resp = api_client.get(f"/api/tasks/{task_id}")
if isinstance(task_resp, dict) and task_resp.get("error"):
return jsonify(task_resp), task_resp.get("status_code", 500)
recipe_id = task_resp.get("recipe_id")
if recipe_id and not _recipe_allowed_at_station(recipe_id):
abort(403, description=_("Ricetta non assegnata a questa stazione"))
version_id = task_resp.get("version_id")
progress = _task_progress(version_id, _current_run_id())
row = progress.get(task_id, {
"task_id": task_id, "quotes": 0, "measured": 0, "state": "empty",
})
return jsonify(row), 200
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routes: Out-of-tolerance authorisation (point 5) # Routes: Out-of-tolerance authorisation (point 5)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -167,6 +167,87 @@ class TestTaskList:
assert "Misura" in resp.get_data(as_text=True) assert "Misura" in resp.get_data(as_text=True)
class TestApiTaskProgress:
"""GET /measure/api/task-progress/<task_id> tests (gitea#37.8).
The browser cannot call /api/measurements/task-progress directly (no
X-API-Key); this proxy forwards it with the same version_id/
production_run_id scoping _task_progress() already uses for task_list.
"""
@pytest.fixture(autouse=True)
def _station_configured(self, monkeypatch):
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
def test_returns_this_task_progress_row(self, logged_in_client, mock_api_client):
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
"/api/production-runs/current": None,
"/api/measurements/task-progress": {
"tasks": [{"task_id": 7, "quotes": 3, "measured": 1, "state": "partial"}],
},
})
resp = logged_in_client.get("/measure/api/task-progress/7")
assert resp.status_code == 200
assert resp.get_json() == {
"task_id": 7, "quotes": 3, "measured": 1, "state": "partial",
}
# No production run open: the backend call must not carry a
# production_run_id it would then use to scope by the wrong cycle.
progress_call = next(
call for call in mock_api_client.get.call_args_list
if call.args[0] == "/api/measurements/task-progress"
)
assert progress_call.kwargs["params"] == {"version_id": 42}
def test_task_absent_from_progress_defaults_to_empty(
self, logged_in_client, mock_api_client,
):
"""A task with no quotes yet has no row in the backend's answer."""
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
"/api/production-runs/current": None,
"/api/measurements/task-progress": {"tasks": []},
})
resp = logged_in_client.get("/measure/api/task-progress/7")
assert resp.status_code == 200
assert resp.get_json() == {
"task_id": 7, "quotes": 0, "measured": 0, "state": "empty",
}
def test_refuses_a_recipe_not_assigned_to_this_station(
self, logged_in_client, mock_api_client,
):
"""Same #24 guard task_execute() uses: no bypass through this proxy."""
mock_api_client.get_station_recipes.return_value = [{"id": 99}]
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
})
resp = logged_in_client.get("/measure/api/task-progress/7")
assert resp.status_code == 403
def test_propagates_a_task_not_found(self, logged_in_client, mock_api_client):
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/999": {"error": True, "status_code": 404, "detail": "not found"},
})
resp = logged_in_client.get("/measure/api/task-progress/999")
assert resp.status_code == 404
def test_requires_login(self, client):
resp = client.get("/measure/api/task-progress/7")
assert resp.status_code in (302, 401)
class TestStartSequence: class TestStartSequence:
"""GET /measure/start/<recipe_id> — the operator's way in.""" """GET /measure/start/<recipe_id> — the operator's way in."""