diff --git a/src/frontend/flask_app/blueprints/measure.py b/src/frontend/flask_app/blueprints/measure.py index db8dee8..9f83b76 100644 --- a/src/frontend/flask_app/blueprints/measure.py +++ b/src/frontend/flask_app/blueprints/measure.py @@ -565,6 +565,38 @@ def save_measurement(): 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/", 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) # --------------------------------------------------------------------------- diff --git a/src/frontend/flask_app/tests/test_measure.py b/src/frontend/flask_app/tests/test_measure.py index e7512d0..28b72eb 100644 --- a/src/frontend/flask_app/tests/test_measure.py +++ b/src/frontend/flask_app/tests/test_measure.py @@ -167,6 +167,87 @@ class TestTaskList: assert "Misura" in resp.get_data(as_text=True) +class TestApiTaskProgress: + """GET /measure/api/task-progress/ 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: """GET /measure/start/ — the operator's way in."""