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:
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user