bfe66a5710
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>
179 lines
6.7 KiB
Python
179 lines
6.7 KiB
Python
"""Production runs router - open, read, and drive the life of a production.
|
|
|
|
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, HTTPException, Query, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src.backend.api.middleware.api_key import get_current_user
|
|
from src.backend.database import get_db
|
|
from src.backend.models.api.production import (
|
|
CycleCompletePayload,
|
|
ProductionEventResponse,
|
|
ProductionRunCreate,
|
|
ProductionRunResponse,
|
|
ProductionRunWithEventsResponse,
|
|
RemeasurePayload,
|
|
SupervisorAction,
|
|
)
|
|
from src.backend.models.orm.production import ProductionRun
|
|
from src.backend.models.orm.user import User
|
|
from src.backend.services import auth_service, production_service
|
|
|
|
router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
|
|
|
_DERIVED = {
|
|
"seconds_to_next_measurement", "overdue", "server_time", "measurement_task_ids",
|
|
}
|
|
|
|
|
|
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(
|
|
**ProductionRunResponse.model_validate(run).model_dump(exclude=_DERIVED),
|
|
**production_service.describe(run),
|
|
measurement_task_ids=await production_service.measurement_task_ids(
|
|
db, run.version_id,
|
|
),
|
|
)
|
|
|
|
|
|
@router.post("", response_model=ProductionRunResponse, status_code=status.HTTP_201_CREATED)
|
|
async def open_production_run(
|
|
data: ProductionRunCreate,
|
|
user: User = Depends(_require_run_operator),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Start a production at a station."""
|
|
run = await production_service.open_run(db, data, user)
|
|
return await _as_response(db, run)
|
|
|
|
|
|
@router.get("/current", response_model=ProductionRunResponse | None)
|
|
async def get_current_production_run(
|
|
station_code: str = Query(..., min_length=1),
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""The run open at this station, or null.
|
|
|
|
This is what every page asks on load instead of keeping the timer in memory.
|
|
"""
|
|
station = await production_service.get_station_by_code(db, station_code)
|
|
run = await production_service.get_open_run_for_station(db, station.id)
|
|
return await _as_response(db, run) if run is not None else None
|
|
|
|
|
|
@router.get("/{run_id}", response_model=ProductionRunWithEventsResponse)
|
|
async def get_production_run(
|
|
run_id: int,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""A run with its full trace - the history a production did not have before."""
|
|
run = await production_service.get_run(db, run_id)
|
|
events = await production_service.list_run_events(db, run_id)
|
|
return ProductionRunWithEventsResponse(
|
|
**(await _as_response(db, run)).model_dump(),
|
|
events=[ProductionEventResponse.model_validate(e) for e in events],
|
|
)
|
|
|
|
|
|
@router.post("/{run_id}/cycle", response_model=ProductionRunResponse)
|
|
async def complete_measurement_cycle(
|
|
run_id: int,
|
|
payload: CycleCompletePayload | None = None,
|
|
user: User = Depends(_require_run_operator),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Record a finished measurement task; the last one restarts the interval."""
|
|
run = await production_service.get_run(db, run_id)
|
|
run = await production_service.complete_cycle(
|
|
db, run, user,
|
|
task_id=payload.task_id if payload else None,
|
|
note=payload.note if payload else None,
|
|
)
|
|
return await _as_response(db, run)
|
|
|
|
|
|
@router.post("/{run_id}/remeasure", response_model=ProductionRunResponse)
|
|
async def remeasure_in_cycle(
|
|
run_id: int,
|
|
payload: RemeasurePayload | None = None,
|
|
user: User = Depends(_require_run_operator),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Turn the piece over and measure again, without closing the cycle."""
|
|
run = await production_service.get_run(db, run_id)
|
|
run = await production_service.remeasure(
|
|
db, run, user,
|
|
task_id=payload.task_id if payload else None,
|
|
note=payload.note if payload else None,
|
|
)
|
|
return await _as_response(db, run)
|
|
|
|
|
|
@router.post("/{run_id}/pause", response_model=ProductionRunResponse)
|
|
async def pause_production_run(
|
|
run_id: int,
|
|
action: SupervisorAction,
|
|
user: User = Depends(_require_run_operator),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Fermo linea - requires a supervisor."""
|
|
run = await production_service.get_run(db, run_id)
|
|
supervisor = await auth_service.authorise_supervisor(
|
|
db, action.supervisor_username, action.supervisor_password,
|
|
)
|
|
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
|
|
return await _as_response(db, run)
|
|
|
|
|
|
@router.post("/{run_id}/resume", response_model=ProductionRunResponse)
|
|
async def resume_production_run(
|
|
run_id: int,
|
|
action: SupervisorAction,
|
|
user: User = Depends(_require_run_operator),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Restart a stopped line - requires a supervisor."""
|
|
run = await production_service.get_run(db, run_id)
|
|
supervisor = await auth_service.authorise_supervisor(
|
|
db, action.supervisor_username, action.supervisor_password,
|
|
)
|
|
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
|
|
return await _as_response(db, run)
|
|
|
|
|
|
@router.post("/{run_id}/close", response_model=ProductionRunResponse)
|
|
async def close_production_run(
|
|
run_id: int,
|
|
action: SupervisorAction,
|
|
user: User = Depends(_require_run_operator),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Fine produzione - requires a supervisor. Stops the timer for good."""
|
|
run = await production_service.get_run(db, run_id)
|
|
supervisor = await auth_service.authorise_supervisor(
|
|
db, action.supervisor_username, action.supervisor_password,
|
|
)
|
|
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
|
|
return await _as_response(db, run)
|