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
+22 -7
View File
@@ -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."""
+30 -1
View File
@@ -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(
+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
# ---------------------------------------------------------------------------