fix(measure): filtro stazione anche su barcode, avvio sequenza e task_execute (#24)

_recipe_allowed_at_station() riusa get_station_recipes() (stessa fonte di
/measure/select) per lookup_barcode, start_sequence e task_execute: prima
solo l'elenco filtrava, il barcode e i link diretti lasciavano misurare
ricette non assegnate alla stazione. Ricetta non assegnata -> abort(403)
con messaggio "ricetta non assegnata a questa stazione" (pagine, come già
fa role_required) o JSON 403 (lookup-barcode). Aggiornati i test esistenti
che ora richiedono get_station_recipes/STATION_CODE mockati.
Backend (ruoli sui run, StationRecipeAssignment su open_run) resta al
lotto A1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:47:30 +02:00
parent f739f963c7
commit befdce5e6a
6 changed files with 163 additions and 20 deletions
+40 -2
View File
@@ -1,6 +1,6 @@
"""MeasurementTec blueprint - recipe selection and measurement execution."""
from flask import (
Blueprint, flash, jsonify, redirect, render_template,
Blueprint, abort, flash, jsonify, redirect, render_template,
request, session, url_for,
)
from flask_babel import gettext as _
@@ -182,6 +182,32 @@ def _current_run_id() -> int | None:
return resp.get("id")
def _recipe_allowed_at_station(recipe_id: int) -> bool:
"""Is this recipe among the ones the picker shows for the current station? (#24)
The picker (/measure/select) already filters through
get_station_recipes(); barcode lookup, the guided-sequence shortcut and a
direct link into task_execute all bypass that page, so without this check
an operator could measure a recipe never assigned to this station just by
knowing (or scanning) its code. Same source of truth, same call - no
separate policy to keep in sync. Anything we cannot confirm (no station
configured, the call failing) is refused, not let through.
"""
station_code, _overridden = _current_station()
if not station_code:
return False
try:
resp = api_client.get_station_recipes(station_code)
except Exception:
return False
if isinstance(resp, dict) and resp.get("error"):
return False
recipes = resp.get("items", resp) if isinstance(resp, dict) else resp
if not isinstance(recipes, list):
return False
return any(isinstance(r, dict) and r.get("id") == recipe_id for r in recipes)
# ---------------------------------------------------------------------------
# Route: start the guided sequence
# ---------------------------------------------------------------------------
@@ -207,6 +233,9 @@ def start_sequence(recipe_id: int):
)
return redirect(url_for("measure.select_recipe"))
if not _recipe_allowed_at_station(recipe_id):
abort(403, description=_("Ricetta non assegnata a questa stazione"))
missing = _missing_traceability(recipe_resp, lot_number, serial_number)
if missing:
flash(
@@ -281,11 +310,14 @@ def task_execute(task_id: int):
)
return redirect(url_for("measure.select_recipe"))
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"))
lot_number = session.get("lot_number", "")
serial_number = session.get("serial_number", "")
# Load all task IDs for this recipe (ordered) for auto-advance
recipe_id = task_resp.get("recipe_id")
all_task_ids = []
measurement_interval_minutes = None
# Allowed until the recipe says otherwise: a recipe that could not be read must
@@ -405,6 +437,12 @@ def lookup_barcode():
"detail": resp.get("detail", _("Ricetta non trovata")),
}), 404
if not _recipe_allowed_at_station(resp.get("id")):
return jsonify({
"error": True,
"detail": _("Ricetta non assegnata a questa stazione"),
}), 403
return jsonify(resp)
@@ -170,7 +170,15 @@ class TestTaskList:
class TestStartSequence:
"""GET /measure/start/<recipe_id> — the operator's way in."""
@pytest.fixture(autouse=True)
def _station_configured(self, monkeypatch):
# start_sequence() checks recipe-station assignment (#24); STATION_CODE
# defaults to unset in the test env, so every test in this class needs it.
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
def test_start_goes_to_the_first_task(self, logged_in_client, mock_api_client):
# start_sequence() only accepts a recipe assigned to this station (#24).
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/1/tasks": [
{"id": 31, "title": "Nota", "order_index": 1},
@@ -186,6 +194,7 @@ class TestStartSequence:
def test_start_remembers_the_traceability(self, logged_in_client, mock_api_client):
"""The list used to be where lot and serial were stored; the sequence
skips it, so the storing has to happen on the way in."""
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/1/tasks": [{"id": 30, "title": "Prima", "order_index": 0}],
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
@@ -200,6 +209,7 @@ class TestStartSequence:
self, logged_in_client, mock_api_client,
):
"""Point 8 holds on this door too: it is a way into the measurement."""
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/1/tasks": [{"id": 30, "title": "Prima", "order_index": 0}],
"/api/recipes/1": {
@@ -215,6 +225,7 @@ class TestStartSequence:
def test_start_without_tasks_falls_back_to_the_list(
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/recipes/1/tasks": [],
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
@@ -224,6 +235,18 @@ class TestStartSequence:
assert resp.status_code == 302
assert resp.headers["Location"].endswith("/measure/tasks/1")
def test_start_refuses_recipe_not_assigned_to_station(
self, logged_in_client, mock_api_client,
):
"""#24: a recipe not on this station's list cannot be started via a direct link."""
mock_api_client.get_station_recipes.return_value = [{"id": 99}]
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
})
resp = logged_in_client.get("/measure/start/1")
assert resp.status_code == 403
class TestTaskComplete:
"""GET /measure/complete/<recipe_id> tests."""
@@ -4,6 +4,8 @@ from unittest.mock import patch, MagicMock
import pytest
from tests.conftest import api_get_router
@pytest.fixture(autouse=True)
def _restore_measure_module():
@@ -151,3 +153,66 @@ def test_station_switch_normalises_case(logged_in_client, monkeypatch):
mock_api.get_station_recipes.return_value = []
logged_in_client.get("/measure/select?station=st-other")
assert _station_calls(mock_api) == ["ST-OTHER", "ST-OTHER"]
# ---------------------------------------------------------------------------
# gitea#24: barcode lookup, the guided-sequence shortcut and a direct link
# into task_execute must not let an operator bypass the picker's station
# filter just by knowing (or scanning) a recipe's code/id.
# ---------------------------------------------------------------------------
def test_lookup_barcode_rejects_recipe_not_assigned_to_station(logged_in_client, monkeypatch):
_reload_measure(monkeypatch, station_code="ST-TEST")
from blueprints import measure as measure_mod
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get.side_effect = api_get_router({
"/api/recipes/code/R-OTHER": {"id": 99, "code": "R-OTHER", "name": "Other"},
})
mock_api.get_station_recipes.return_value = [{"id": 1, "code": "R1"}]
resp = logged_in_client.post("/measure/lookup-barcode", json={"code": "R-OTHER"})
assert resp.status_code == 403
body = resp.get_json()
assert body["error"] is True
assert "assegnata" in body["detail"].lower()
def test_lookup_barcode_accepts_recipe_assigned_to_station(logged_in_client, monkeypatch):
_reload_measure(monkeypatch, station_code="ST-TEST")
from blueprints import measure as measure_mod
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get.side_effect = api_get_router({
"/api/recipes/code/R1": {"id": 1, "code": "R1", "name": "Assigned"},
})
mock_api.get_station_recipes.return_value = [{"id": 1, "code": "R1"}]
resp = logged_in_client.post("/measure/lookup-barcode", json={"code": "R1"})
assert resp.status_code == 200
assert resp.get_json()["id"] == 1
def test_start_sequence_rejects_recipe_not_assigned_to_station(logged_in_client, monkeypatch):
_reload_measure(monkeypatch, station_code="ST-TEST")
from blueprints import measure as measure_mod
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get.side_effect = api_get_router({
"/api/recipes/99": {"id": 99, "code": "R-OTHER", "name": "Other"},
})
mock_api.get_station_recipes.return_value = [{"id": 1, "code": "R1"}]
resp = logged_in_client.get("/measure/start/99")
assert resp.status_code == 403
def test_task_execute_rejects_recipe_not_assigned_to_station(logged_in_client, monkeypatch):
_reload_measure(monkeypatch, station_code="ST-TEST")
from blueprints import measure as measure_mod
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get.side_effect = api_get_router({
"/api/tasks/10": {"id": 10, "recipe_id": 99, "subtasks": []},
})
mock_api.get_station_recipes.return_value = [{"id": 1, "code": "R1"}]
resp = logged_in_client.get("/measure/execute/10")
assert resp.status_code == 403
@@ -47,8 +47,14 @@ def _operator_only(client):
sess["user"] = user
def _execute_page(client, mock_api_client):
def _execute_page(client, mock_api_client, monkeypatch):
_italian(client)
# task_execute() only renders a recipe assigned to this station (#24).
# STATION_CODE defaults to unset in the test env, so it has to be forced
# here for the same reason test_the_selection_screen_starts_the_sequence
# forces it below.
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = [{"id": 3}]
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/11": TASK,
"/api/recipes/3/tasks": [{"id": 11, "order_index": 0}],
@@ -147,9 +153,9 @@ def test_an_untouched_task_says_nothing(logged_in_client, mock_api_client):
def test_the_header_offers_completato_not_the_summary(
logged_in_client, mock_api_client,
logged_in_client, mock_api_client, monkeypatch,
):
html = _execute_page(logged_in_client, mock_api_client)
html = _execute_page(logged_in_client, mock_api_client, monkeypatch)
assert 'x-data="taskExecute()"' in html
# The header control moves the operator on and is gated by the same rule as
# the footer, rather than jumping out of the sequence to a recap.
@@ -158,19 +164,19 @@ def test_the_header_offers_completato_not_the_summary(
def test_fine_ciclo_is_shown_before_it_can_be_used(
logged_in_client, mock_api_client,
logged_in_client, mock_api_client, monkeypatch,
):
"""It used to appear only once the task was already finished."""
html = _execute_page(logged_in_client, mock_api_client)
html = _execute_page(logged_in_client, mock_api_client, monkeypatch)
assert 'x-show="!cycleConfirmed"' in html
assert ':disabled="!isComplete"' in html
assert "Fine ciclo misura" in html
def test_the_missing_quotes_are_counted_out_loud(
logged_in_client, mock_api_client,
logged_in_client, mock_api_client, monkeypatch,
):
html = _execute_page(logged_in_client, mock_api_client)
html = _execute_page(logged_in_client, mock_api_client, monkeypatch)
assert "missingQuotesLabel" in html
assert "Mancano" in html
@@ -181,12 +187,12 @@ def test_the_missing_quotes_are_counted_out_loud(
def test_the_operator_flow_never_links_to_the_statistics(
logged_in_client, mock_api_client,
logged_in_client, mock_api_client, monkeypatch,
):
_operator_only(logged_in_client)
pages = [
_list_page(logged_in_client, mock_api_client),
_execute_page(logged_in_client, mock_api_client),
_execute_page(logged_in_client, mock_api_client, monkeypatch),
]
for html in pages:
assert "/statistics" not in html
@@ -71,7 +71,11 @@ TASK = {
}
def _execute_page(client, mock_api_client, allow_manual):
def _execute_page(client, mock_api_client, monkeypatch, allow_manual):
# task_execute() only renders a recipe assigned to this station (#24).
# STATION_CODE defaults to unset in the test env, so it has to be forced.
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = [{"id": 3}]
mock_api_client.get.side_effect = [
TASK,
[{"id": 11, "order_index": 0}],
@@ -86,9 +90,9 @@ def _execute_page(client, mock_api_client, allow_manual):
def test_keypad_digits_are_absent_when_typing_is_forbidden(
logged_in_client, mock_api_client,
logged_in_client, mock_api_client, monkeypatch,
):
html = _execute_page(logged_in_client, mock_api_client, allow_manual=False)
html = _execute_page(logged_in_client, mock_api_client, monkeypatch, allow_manual=False)
assert "allowManual: false" in html
assert "addDigit('7')" not in html, "i tasti numerici non devono essere in pagina"
# The corrections and the confirm stay: a wrong caliper reading has to be
@@ -98,17 +102,19 @@ def test_keypad_digits_are_absent_when_typing_is_forbidden(
def test_keypad_is_there_when_the_recipe_allows_typing(
logged_in_client, mock_api_client,
logged_in_client, mock_api_client, monkeypatch,
):
html = _execute_page(logged_in_client, mock_api_client, allow_manual=True)
html = _execute_page(logged_in_client, mock_api_client, monkeypatch, allow_manual=True)
assert "allowManual: true" in html
assert "addDigit('7')" in html
def test_keypad_stays_when_the_recipe_cannot_be_read(
logged_in_client, mock_api_client,
logged_in_client, mock_api_client, monkeypatch,
):
"""A recipe that failed to load must not silently take the keypad away."""
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = [{"id": 3}]
mock_api_client.get.side_effect = [
TASK,
[{"id": 11, "order_index": 0}],
@@ -125,10 +131,10 @@ def test_keypad_stays_when_the_recipe_cannot_be_read(
def test_the_operator_sees_the_description_formatted(
logged_in_client, mock_api_client,
logged_in_client, mock_api_client, monkeypatch,
):
"""It was written for the operator, and until now only the maker saw it."""
html = _execute_page(logged_in_client, mock_api_client, allow_manual=True)
html = _execute_page(logged_in_client, mock_api_client, monkeypatch, allow_manual=True)
assert "prima riga<br><strong>grassetto</strong>" in html
@@ -227,7 +227,7 @@ def mock_measure_api():
yield mock
def test_task_execute_inline_js_is_valid(logged_in_client, mock_measure_api):
def test_task_execute_inline_js_is_valid(logged_in_client, mock_measure_api, monkeypatch):
"""The measurement screen carries the most inline JS in the app.
It drives the numpad, the caliper burst detection, the supervisor modal and -
@@ -235,6 +235,9 @@ def test_task_execute_inline_js_is_valid(logged_in_client, mock_measure_api):
here kills every binding on the screen the operator actually works in.
"""
_force_italian(logged_in_client)
# STATION_CODE defaults to unset in the test env, so it has to be forced
# for task_execute()'s station-assignment check (#24).
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
task = {
"id": 11,
"recipe_id": 3,
@@ -250,6 +253,8 @@ def test_task_execute_inline_js_is_valid(logged_in_client, mock_measure_api):
"unit": "mm",
}],
}
# task_execute() only renders a recipe assigned to this station (#24).
mock_measure_api.get_station_recipes.return_value = [{"id": 3}]
mock_measure_api.get.side_effect = [
task, # /api/tasks/11
[{"id": 11, "order_index": 0}], # /api/recipes/3/tasks