Files
TieMeasureFlow/src/backend/api/routers/production.py
T

207 lines
7.9 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.models.api.monitor import MonitorRun
from src.backend.services import auth_service, monitor_service, production_service
router = APIRouter(prefix="/api/production-runs", tags=["production"])
_DERIVED = {
"seconds_to_next_measurement", "overdue", "server_time", "measurement_task_ids",
"close_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,
),
# #53: task da compilare alla fine produzione
close_task_ids=await production_service.close_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
async def _require_high_authority(user: User = Depends(get_current_user)) -> User:
"""Monitor of all stations (#57): admin or Supervisor (capoturno) only."""
if user.is_admin or user.has_role("Supervisor"):
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Supervisor role required",
)
@router.get("/open", response_model=list[MonitorRun])
async def list_open_production_runs(
user: User = Depends(_require_high_authority),
db: AsyncSession = Depends(get_db),
):
"""Every open run with timer, last event and recent measurements (#57).
Declared before /{run_id} so "open" is not parsed as a run id.
"""
return await monitor_service.list_open_runs(db)
@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,
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
)
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,
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
)
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,
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
)
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
return await _as_response(db, run)