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