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.""" """MeasurementTec blueprint - recipe selection and measurement execution."""
from flask import ( from flask import (
Blueprint, flash, jsonify, redirect, render_template, Blueprint, abort, flash, jsonify, redirect, render_template,
request, session, url_for, request, session, url_for,
) )
from flask_babel import gettext as _ from flask_babel import gettext as _
@@ -182,6 +182,32 @@ def _current_run_id() -> int | None:
return resp.get("id") 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 # Route: start the guided sequence
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -207,6 +233,9 @@ def start_sequence(recipe_id: int):
) )
return redirect(url_for("measure.select_recipe")) 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) missing = _missing_traceability(recipe_resp, lot_number, serial_number)
if missing: if missing:
flash( flash(
@@ -281,11 +310,14 @@ def task_execute(task_id: int):
) )
return redirect(url_for("measure.select_recipe")) 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", "") lot_number = session.get("lot_number", "")
serial_number = session.get("serial_number", "") serial_number = session.get("serial_number", "")
# Load all task IDs for this recipe (ordered) for auto-advance # Load all task IDs for this recipe (ordered) for auto-advance
recipe_id = task_resp.get("recipe_id")
all_task_ids = [] all_task_ids = []
measurement_interval_minutes = None measurement_interval_minutes = None
# Allowed until the recipe says otherwise: a recipe that could not be read must # 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")), "detail": resp.get("detail", _("Ricetta non trovata")),
}), 404 }), 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) return jsonify(resp)
@@ -170,7 +170,15 @@ class TestTaskList:
class TestStartSequence: class TestStartSequence:
"""GET /measure/start/<recipe_id> — the operator's way in.""" """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): 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({ mock_api_client.get.side_effect = api_get_router({
"/api/recipes/1/tasks": [ "/api/recipes/1/tasks": [
{"id": 31, "title": "Nota", "order_index": 1}, {"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): 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 """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.""" 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({ mock_api_client.get.side_effect = api_get_router({
"/api/recipes/1/tasks": [{"id": 30, "title": "Prima", "order_index": 0}], "/api/recipes/1/tasks": [{"id": 30, "title": "Prima", "order_index": 0}],
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"}, "/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
@@ -200,6 +209,7 @@ class TestStartSequence:
self, logged_in_client, mock_api_client, self, logged_in_client, mock_api_client,
): ):
"""Point 8 holds on this door too: it is a way into the measurement.""" """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({ mock_api_client.get.side_effect = api_get_router({
"/api/recipes/1/tasks": [{"id": 30, "title": "Prima", "order_index": 0}], "/api/recipes/1/tasks": [{"id": 30, "title": "Prima", "order_index": 0}],
"/api/recipes/1": { "/api/recipes/1": {
@@ -215,6 +225,7 @@ class TestStartSequence:
def test_start_without_tasks_falls_back_to_the_list( def test_start_without_tasks_falls_back_to_the_list(
self, logged_in_client, mock_api_client, 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({ mock_api_client.get.side_effect = api_get_router({
"/api/recipes/1/tasks": [], "/api/recipes/1/tasks": [],
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"}, "/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
@@ -224,6 +235,18 @@ class TestStartSequence:
assert resp.status_code == 302 assert resp.status_code == 302
assert resp.headers["Location"].endswith("/measure/tasks/1") 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: class TestTaskComplete:
"""GET /measure/complete/<recipe_id> tests.""" """GET /measure/complete/<recipe_id> tests."""
@@ -4,6 +4,8 @@ from unittest.mock import patch, MagicMock
import pytest import pytest
from tests.conftest import api_get_router
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _restore_measure_module(): 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 = [] mock_api.get_station_recipes.return_value = []
logged_in_client.get("/measure/select?station=st-other") logged_in_client.get("/measure/select?station=st-other")
assert _station_calls(mock_api) == ["ST-OTHER", "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 sess["user"] = user
def _execute_page(client, mock_api_client): def _execute_page(client, mock_api_client, monkeypatch):
_italian(client) _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({ mock_api_client.get.side_effect = api_get_router({
"/api/tasks/11": TASK, "/api/tasks/11": TASK,
"/api/recipes/3/tasks": [{"id": 11, "order_index": 0}], "/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( 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 assert 'x-data="taskExecute()"' in html
# The header control moves the operator on and is gated by the same rule as # 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. # 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( 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.""" """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 'x-show="!cycleConfirmed"' in html
assert ':disabled="!isComplete"' in html assert ':disabled="!isComplete"' in html
assert "Fine ciclo misura" in html assert "Fine ciclo misura" in html
def test_the_missing_quotes_are_counted_out_loud( 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 "missingQuotesLabel" in html
assert "Mancano" 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( 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) _operator_only(logged_in_client)
pages = [ pages = [
_list_page(logged_in_client, mock_api_client), _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: for html in pages:
assert "/statistics" not in html 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 = [ mock_api_client.get.side_effect = [
TASK, TASK,
[{"id": 11, "order_index": 0}], [{"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( 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 "allowManual: false" in html
assert "addDigit('7')" not in html, "i tasti numerici non devono essere in pagina" 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 # 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( 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 "allowManual: true" in html
assert "addDigit('7')" in html assert "addDigit('7')" in html
def test_keypad_stays_when_the_recipe_cannot_be_read( 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.""" """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 = [ mock_api_client.get.side_effect = [
TASK, TASK,
[{"id": 11, "order_index": 0}], [{"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( 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.""" """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 assert "prima riga<br><strong>grassetto</strong>" in html
@@ -227,7 +227,7 @@ def mock_measure_api():
yield mock 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. """The measurement screen carries the most inline JS in the app.
It drives the numpad, the caliper burst detection, the supervisor modal and - 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. here kills every binding on the screen the operator actually works in.
""" """
_force_italian(logged_in_client) _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 = { task = {
"id": 11, "id": 11,
"recipe_id": 3, "recipe_id": 3,
@@ -250,6 +253,8 @@ def test_task_execute_inline_js_is_valid(logged_in_client, mock_measure_api):
"unit": "mm", "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 = [ mock_measure_api.get.side_effect = [
task, # /api/tasks/11 task, # /api/tasks/11
[{"id": 11, "order_index": 0}], # /api/recipes/3/tasks [{"id": 11, "order_index": 0}], # /api/recipes/3/tasks