fix(production): ruolo e assegnazione stazione su apertura/avanzamento run (#24 backend)

api/routers/production.py: open/cycle/remeasure/pause/resume/close accettavano
qualunque utente autenticato (get_current_user). Nuova dipendenza locale
_require_run_operator (MeasurementTec o Supervisor o admin) sui sei endpoint
di ciclo vita; le letture (current, {run_id} GET) restano aperte a tutti gli
autenticati. Per pause/resume/close si aggiunge al controllo esistente delle
credenziali del supervisore in corpo, che riguarda un'altra persona (chi
autorizza), non chi opera.

services/production_service.py open_run: nessun controllo su recipe.active ne'
sull'assegnazione ricetta-stazione. Nuovo _require_recipe_assigned_to_station:
se la stazione ha almeno un'assegnazione (StationRecipeAssignment), la ricetta
deve esserci, altrimenti 409; una stazione senza alcuna assegnazione resta
libera - stessa regola del filtro in lettura di station_service.list_station_recipes
(join che senza righe non filtra nulla). Ricetta non active -> 409.

NON TOCCATO: src/frontend/flask_app/blueprints/measure.py (stesso filtro sul
lato Flask, lotto B2) e test_measure_station_filter.py - altro agente ci
lavora in parallelo sulla stessa cartella.

Test: src/backend/tests/test_production_runs.py, 6 nuovi test (ruolo su
open/pause, assegnazione rifiutata/accettata/libera, ricetta inattiva).
PROVA: uv run --extra server --extra dev pytest src/backend/tests/test_production_runs.py -q -> 34 passed

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:48:17 +02:00
parent 77d95fdcc4
commit bfe66a5710
3 changed files with 143 additions and 8 deletions
+91
View File
@@ -175,6 +175,97 @@ async def test_open_run_rejects_unknown_station(
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Role and station-assignment gate (gitea#24)
# ---------------------------------------------------------------------------
async def test_open_run_requires_measurement_tec_or_supervisor(
client: AsyncClient, maker_user, admin_user, db_session,
):
"""A Maker with no floor role must not be able to start a production."""
station = await _station(db_session, admin_user.id, code="ST-ROLE")
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-ROLE")
resp = await _open(client, maker_user, station, recipe)
assert resp.status_code == 403
async def test_open_run_rejects_recipe_not_assigned_to_station(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
from src.backend.services import station_service
station = await _station(db_session, admin_user.id, code="ST-ASSIGN")
assigned_recipe = await _recipe_with_interval(
db_session, admin_user.id, 15, code="REC-ASSIGNED"
)
other_recipe = await _recipe_with_interval(
db_session, admin_user.id, 15, code="REC-OTHER"
)
await station_service.assign_recipe(db_session, station.id, assigned_recipe.id, admin_user)
await db_session.commit()
resp = await _open(client, measurement_tec_user, station, other_recipe)
assert resp.status_code == 409
async def test_open_run_allows_recipe_assigned_to_station(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
from src.backend.services import station_service
station = await _station(db_session, admin_user.id, code="ST-ASSIGNOK")
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-ASSIGNOK")
await station_service.assign_recipe(db_session, station.id, recipe.id, admin_user)
await db_session.commit()
resp = await _open(client, measurement_tec_user, station, recipe)
assert resp.status_code == 201
async def test_open_run_allows_any_recipe_when_station_has_no_assignment(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
"""An unconfigured station is not restricted - matches the read-side filter
in station_service.list_station_recipes (empty assignments = not filtering)."""
station = await _station(db_session, admin_user.id, code="ST-FREE")
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-FREE")
resp = await _open(client, measurement_tec_user, station, recipe)
assert resp.status_code == 201
async def test_open_run_rejects_inactive_recipe(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
station = await _station(db_session, admin_user.id, code="ST-INACTIVE")
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-INACTIVE")
recipe.active = False
await db_session.commit()
resp = await _open(client, measurement_tec_user, station, recipe)
assert resp.status_code == 409
async def test_pause_requires_measurement_tec_or_supervisor_actor(
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
):
"""The role gate applies to the acting user - separate from the supervisor
credentials pause already requires in its own body."""
station = await _station(db_session, admin_user.id, code="ST-ROLEPAUSE")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-ROLEPAUSE")
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
supervisor, password = await _supervisor(db_session, username="capo-rolepause")
resp = await client.post(
f"/api/production-runs/{run_id}/pause",
headers=auth_headers(maker_user),
json={"supervisor_username": supervisor.username, "supervisor_password": password},
)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# Reading the current run - what replaces the in-page state
# ---------------------------------------------------------------------------