feat(stations): ricette in lista, reset per riga e cambio stazione al volo

Punto 7 del documento modifiche del 28/07, terza richiesta di Menoncin.

GET /api/stations ora usa StationWithRecipesResponse, che era gia' definita nello
schema senza che nessun endpoint la usasse. Non costa query in piu':
Station.assignments e' gia' selectin-loaded. In lista compaiono le sole ricette
attive, coerente con cio' che l'operatore vede davvero a quella stazione.

DELETE /api/stations/{id}/recipes azzera le assegnazioni di una stazione senza
eliminarla, cosi' si riassegna da capo. E' idempotente: azzerare una stazione gia'
vuota risponde removed=0, non 404. In tabella e' un pulsante per riga, con
conferma perche' l'azione e' distruttiva.

Il cambio stazione al volo (?station=CODE, override in sessione) serve al
collaudo: senza, provare N stazioni richiede N PC. E' dietro il flag
STATION_SWITCH_ENABLED, default 0. Non e' legato al ruolo admin perche'
/measure/select richiede MeasurementTec, e gatearlo su is_admin avrebbe escluso
proprio chi fa il collaudo. In fabbrica l'identita' della stazione viene
dall'installazione locale, e misurare contro le ricette di un'altra stazione
romperebbe in silenzio la tracciabilita': per questo il codice richiesto e'
validato prima di essere memorizzato - un refuso lascia l'operatore sulla
stazione configurata con un messaggio, invece di incastrarlo su una stazione
inesistente - e quando l'override e' attivo la pagina lo dichiara, con il codice
configurato accanto e un link per ripristinare.

Le traduzioni: pybabel update aveva indovinato dieci voci da stringhe simili
marcandole fuzzy, in entrambi i cataloghi. "Azzera Stazione" era diventato "Crea
Stazione", l'opposto, su un dialogo distruttivo. Non si vedeva perche' la
compilazione salta le fuzzy e ricade sul sorgente italiano, ma sarebbe emerso al
primo build con --use-fuzzy. Tradotte per esteso le 17 stringhe nuove in IT ed EN
e tolti i flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-28 15:42:17 +00:00
parent 28ee44bcf1
commit a159eb2b8b
15 changed files with 1188 additions and 363 deletions
@@ -132,3 +132,47 @@ def test_non_admin_cannot_access(client, mock_admin_api):
sess["user_id"] = 2
resp = client.get("/admin/stations", follow_redirects=False)
assert resp.status_code in (301, 302)
# ---------------------------------------------------------------------------
# Per-row station reset (point 7)
# ---------------------------------------------------------------------------
def test_reset_station_proxy(logged_in_client, mock_admin_api):
mock_admin_api.delete.return_value = {"station_id": 3, "removed": 2}
resp = logged_in_client.delete("/admin/api/stations/3/recipes")
assert resp.status_code == 200
assert resp.get_json() == {"station_id": 3, "removed": 2}
mock_admin_api.delete.assert_called_once_with("/api/stations/3/recipes")
def test_reset_station_propagates_error(logged_in_client, mock_admin_api):
mock_admin_api.delete.return_value = {
"error": True, "status_code": 404, "detail": "Station not found",
}
resp = logged_in_client.delete("/admin/api/stations/999/recipes")
assert resp.status_code == 404
def test_reset_station_distinct_from_single_unassign(logged_in_client, mock_admin_api):
"""The collection route must not swallow the per-recipe delete."""
mock_admin_api.delete.return_value = {}
logged_in_client.delete("/admin/api/stations/3/recipes/7")
mock_admin_api.delete.assert_called_once_with("/api/stations/3/recipes/7")
def test_station_list_page_renders_assigned_recipes(logged_in_client, mock_admin_api):
"""Recipes assigned to a station reach the page so the row can show them."""
mock_admin_api.get.side_effect = [
[{
"id": 1, "code": "ST-001", "name": "Linea A", "location": "Reparto A",
"notes": None, "active": True, "created_by": 1,
"created_at": "2026-04-25T10:00:00",
"recipes": [{"id": 9, "code": "REC-ABC", "name": "Perno", "active": True}],
}],
[{"id": 9, "code": "REC-ABC", "name": "Perno", "active": True}],
]
resp = logged_in_client.get("/admin/stations")
assert resp.status_code == 200
assert b"REC-ABC" in resp.data
@@ -2,13 +2,32 @@
import importlib
from unittest.mock import patch, MagicMock
import pytest
def _reload_measure(monkeypatch, station_code=None):
"""Reload config and measure module under the given STATION_CODE env."""
@pytest.fixture(autouse=True)
def _restore_measure_module():
"""Reload config/measure after each test so env tweaks don't leak.
Reloading runs the module body in its existing namespace, so the view
functions already registered on the app see the refreshed globals. Without
this teardown a station code set here would stay visible to later tests.
Autouse fixtures tear down after monkeypatch, so the env is pristine by then.
"""
yield
import config
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
def _reload_measure(monkeypatch, station_code=None, switch_enabled=False):
"""Reload config and measure module under the given station env."""
if station_code is None:
monkeypatch.delenv("STATION_CODE", raising=False)
else:
monkeypatch.setenv("STATION_CODE", station_code)
monkeypatch.setenv("STATION_SWITCH_ENABLED", "1" if switch_enabled else "0")
import config
importlib.reload(config)
import blueprints.measure
@@ -35,3 +54,100 @@ def test_select_recipe_without_station_code_shows_error(logged_in_client, monkey
assert resp.status_code == 503
body = resp.data.lower()
assert b"station_code" in body or b"stazione" in body
# ---------------------------------------------------------------------------
# Switching the current station from the URL (point 7)
# ---------------------------------------------------------------------------
def _station_calls(mock_api):
"""Station codes passed to get_station_recipes, in order."""
return [c.args[0] for c in mock_api.get_station_recipes.call_args_list]
def test_station_switch_ignored_when_disabled(logged_in_client, monkeypatch):
"""Off by default: on the shop floor the station comes from the install."""
_reload_measure(monkeypatch, station_code="ST-CONF", switch_enabled=False)
from blueprints import measure as measure_mod
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get_station_recipes.return_value = []
resp = logged_in_client.get("/measure/select?station=ST-OTHER")
assert resp.status_code == 200
# The requested station is never even probed.
assert _station_calls(mock_api) == ["ST-CONF"]
def test_station_switch_applies_when_enabled(logged_in_client, monkeypatch):
_reload_measure(monkeypatch, station_code="ST-CONF", switch_enabled=True)
from blueprints import measure as measure_mod
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get_station_recipes.return_value = []
resp = logged_in_client.get("/measure/select?station=ST-OTHER")
assert resp.status_code == 200
# Probed first to validate, then used to load the recipes.
assert _station_calls(mock_api) == ["ST-OTHER", "ST-OTHER"]
assert b"ST-OTHER" in resp.data
def test_station_switch_survives_next_request(logged_in_client, monkeypatch):
"""The override lives in the session, so it holds without repeating the param."""
_reload_measure(monkeypatch, station_code="ST-CONF", switch_enabled=True)
from blueprints import measure as measure_mod
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get_station_recipes.return_value = []
logged_in_client.get("/measure/select?station=ST-OTHER")
mock_api.get_station_recipes.reset_mock()
resp = logged_in_client.get("/measure/select")
assert resp.status_code == 200
assert _station_calls(mock_api) == ["ST-OTHER"]
def test_station_switch_rejects_unknown_code(logged_in_client, monkeypatch):
"""A typo must not strand the operator on a station that does not exist."""
_reload_measure(monkeypatch, station_code="ST-CONF", switch_enabled=True)
from blueprints import measure as measure_mod
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get_station_recipes.side_effect = [
{"error": True, "status_code": 404, "detail": "Station not found"},
[],
]
resp = logged_in_client.get("/measure/select?station=ST-NOPE")
assert resp.status_code == 200
# Probed, refused, then fell back to the configured station.
assert _station_calls(mock_api) == ["ST-NOPE", "ST-CONF"]
assert b"ST-CONF" in resp.data
def test_station_switch_can_be_cleared(logged_in_client, monkeypatch):
_reload_measure(monkeypatch, station_code="ST-CONF", switch_enabled=True)
from blueprints import measure as measure_mod
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get_station_recipes.return_value = []
logged_in_client.get("/measure/select?station=ST-OTHER")
mock_api.get_station_recipes.reset_mock()
resp = logged_in_client.get("/measure/select?station=")
assert resp.status_code == 200
assert _station_calls(mock_api) == ["ST-CONF"]
def test_station_switch_is_signalled_in_the_page(logged_in_client, monkeypatch):
"""An override must never be silent: wrong-station measurements are untraceable."""
_reload_measure(monkeypatch, station_code="ST-CONF", switch_enabled=True)
from blueprints import measure as measure_mod
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get_station_recipes.return_value = []
resp = logged_in_client.get("/measure/select?station=ST-OTHER")
body = resp.data.decode()
assert "ST-OTHER" in body
# The configured station is named so the deviation is obvious.
assert "ST-CONF" in body
def test_station_switch_normalises_case(logged_in_client, monkeypatch):
_reload_measure(monkeypatch, station_code="ST-CONF", switch_enabled=True)
from blueprints import measure as measure_mod
with patch.object(measure_mod, "api_client") as mock_api:
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"]