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."""