From bfe66a5710678489190e232094e31883e8cfd628 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Mon, 21 Sep 2026 22:48:17 +0200 Subject: [PATCH] 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 --- src/backend/api/routers/production.py | 29 +++++-- src/backend/services/production_service.py | 31 +++++++- src/backend/tests/test_production_runs.py | 91 ++++++++++++++++++++++ 3 files changed, 143 insertions(+), 8 deletions(-) diff --git a/src/backend/api/routers/production.py b/src/backend/api/routers/production.py index 89f4c34..9fdb5f3 100644 --- a/src/backend/api/routers/production.py +++ b/src/backend/api/routers/production.py @@ -4,7 +4,7 @@ Deliberately stateless: nothing lives in process memory. With one station app installed per PC the database is the only shared place, and any station app must be able to ask "what is going on here?" and get the same answer. """ -from fastapi import APIRouter, Depends, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession from src.backend.api.middleware.api_key import get_current_user @@ -29,6 +29,21 @@ _DERIVED = { } +async def _require_run_operator(user: User = Depends(get_current_user)) -> User: + """Open/advance/pause/resume/close a run: MeasurementTec, Supervisor, or admin. + + A Maker with no floor role could otherwise start and drive a production the + same way an operator does (gitea#24) - reading a run stays open to any + authenticated user, only the lifecycle actions are gated. + """ + if user.is_admin or user.has_role("MeasurementTec") or user.has_role("Supervisor"): + return user + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="MeasurementTec or Supervisor role required", + ) + + async def _as_response(db: AsyncSession, run: ProductionRun) -> ProductionRunResponse: """The stored run plus everything the client cannot work out on its own.""" return ProductionRunResponse( @@ -43,7 +58,7 @@ async def _as_response(db: AsyncSession, run: ProductionRun) -> ProductionRunRes @router.post("", response_model=ProductionRunResponse, status_code=status.HTTP_201_CREATED) async def open_production_run( data: ProductionRunCreate, - user: User = Depends(get_current_user), + user: User = Depends(_require_run_operator), db: AsyncSession = Depends(get_db), ): """Start a production at a station.""" @@ -85,7 +100,7 @@ async def get_production_run( async def complete_measurement_cycle( run_id: int, payload: CycleCompletePayload | None = None, - user: User = Depends(get_current_user), + user: User = Depends(_require_run_operator), db: AsyncSession = Depends(get_db), ): """Record a finished measurement task; the last one restarts the interval.""" @@ -102,7 +117,7 @@ async def complete_measurement_cycle( async def remeasure_in_cycle( run_id: int, payload: RemeasurePayload | None = None, - user: User = Depends(get_current_user), + user: User = Depends(_require_run_operator), db: AsyncSession = Depends(get_db), ): """Turn the piece over and measure again, without closing the cycle.""" @@ -119,7 +134,7 @@ async def remeasure_in_cycle( async def pause_production_run( run_id: int, action: SupervisorAction, - user: User = Depends(get_current_user), + user: User = Depends(_require_run_operator), db: AsyncSession = Depends(get_db), ): """Fermo linea - requires a supervisor.""" @@ -135,7 +150,7 @@ async def pause_production_run( async def resume_production_run( run_id: int, action: SupervisorAction, - user: User = Depends(get_current_user), + user: User = Depends(_require_run_operator), db: AsyncSession = Depends(get_db), ): """Restart a stopped line - requires a supervisor.""" @@ -151,7 +166,7 @@ async def resume_production_run( async def close_production_run( run_id: int, action: SupervisorAction, - user: User = Depends(get_current_user), + user: User = Depends(_require_run_operator), db: AsyncSession = Depends(get_db), ): """Fine produzione - requires a supervisor. Stops the timer for good.""" diff --git a/src/backend/services/production_service.py b/src/backend/services/production_service.py index 2df5667..2611351 100644 --- a/src/backend/services/production_service.py +++ b/src/backend/services/production_service.py @@ -23,7 +23,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.backend.models.api.production import ProductionRunCreate from src.backend.models.orm.production import ProductionEvent, ProductionRun from src.backend.models.orm.recipe import Recipe, RecipeVersion -from src.backend.models.orm.station import Station +from src.backend.models.orm.station import Station, StationRecipeAssignment from src.backend.models.orm.task import MEASURING_TASK_TYPES, RecipeTask from src.backend.models.orm.user import User from src.backend.services import ( @@ -106,6 +106,29 @@ async def get_station_by_code(db: AsyncSession, code: str) -> Station: return station +async def _require_recipe_assigned_to_station( + db: AsyncSession, station_id: int, recipe_id: int, +) -> None: + """A configured station only runs what it is assigned; an unconfigured one is free. + + Mirrors station_service.list_station_recipes, the read side an operator's + recipe list is already filtered by: a station with at least one assignment + restricts to those recipes, one with none has not been set up to filter at + all and stays open to any active recipe. + """ + result = await db.execute( + select(StationRecipeAssignment.recipe_id).where( + StationRecipeAssignment.station_id == station_id, + ) + ) + assigned_ids = set(result.scalars().all()) + if assigned_ids and recipe_id not in assigned_ids: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="This recipe is not assigned to this station", + ) + + async def measurement_task_ids(db: AsyncSession, version_id: int) -> list[int]: """Ids of the measurement tasks of a version, in the order they are executed. @@ -199,6 +222,12 @@ async def open_run( raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found", ) + if not recipe.active: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Cannot start a production run with an inactive recipe", + ) + await _require_recipe_assigned_to_station(db, station.id, recipe.id) if data.version_id is not None: version_row = await db.execute( diff --git a/src/backend/tests/test_production_runs.py b/src/backend/tests/test_production_runs.py index 7006447..c76c326 100644 --- a/src/backend/tests/test_production_runs.py +++ b/src/backend/tests/test_production_runs.py @@ -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 # ---------------------------------------------------------------------------