"""Measurements router - create, query, export measurements.""" import csv import io from datetime import datetime from src.backend.models.api.tipi import UtcNaiveInput from src.backend.utils.tempo import as_utc_naive, iso_utc, to_local, utcnow from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi.responses import StreamingResponse from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from src.backend.database import get_db from src.backend.api.middleware.api_key import ( get_current_user, require_measurement_tec, require_metrologist, ) from src.backend.models.orm.measurement import Measurement from src.backend.models.orm.recipe import RecipeVersion from src.backend.models.orm.setting import SystemSetting from src.backend.models.orm.user import User from src.backend.models.api.measurement import ( CurrentCycleResponse, MeasurementAuthorisation, MeasurementBatchCreate, MeasurementCreate, MeasurementListResponse, MeasurementResponse, TaskProgressListResponse, ) from src.backend.services import auth_service, measurement_service from src.backend.services.measurement_service import save_measurement from src.backend.services.production_export_service import neutralise_csv_cell router = APIRouter(prefix="/api/measurements", tags=["measurements"]) @router.post("/", response_model=MeasurementResponse) async def create_measurement( data: MeasurementCreate, user: User = Depends(require_measurement_tec), db: AsyncSession = Depends(get_db), ): """Create a single measurement with auto-calculated pass/fail.""" try: measurement = await save_measurement( db=db, subtask_id=data.subtask_id, version_id=data.version_id, measured_by=user.id, value=data.value, lot_number=data.lot_number, serial_number=data.serial_number, input_method=data.input_method, input_duration_ms=data.input_duration_ms, production_run_id=data.production_run_id, ) return MeasurementResponse.model_validate(measurement) except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) @router.get("/pending-authorisation", response_model=MeasurementResponse | None) async def get_pending_authorisation( version_id: int = Query(..., gt=0), production_run_id: int | None = Query(None, gt=0), user: User = Depends(require_measurement_tec), db: AsyncSession = Depends(get_db), ): """The out-of-tolerance measurement holding this operator up, or null. Asked on load, so that reloading the page is not a way past the gate: the screen finds the same block waiting for it that the server enforces. """ blocking = await measurement_service.pending_authorisation( db, version_id, user.id, production_run_id, ) return MeasurementResponse.model_validate(blocking) if blocking else None @router.get("/task-progress", response_model=TaskProgressListResponse) async def get_task_progress( version_id: int = Query(..., gt=0), production_run_id: int | None = Query(None, gt=0), user: User = Depends(require_measurement_tec), db: AsyncSession = Depends(get_db), ): """How many quotes of each measurement task are already taken. The task list is where an operator picks up work they left behind, and until now it showed a task begun and a task never touched identically. """ tasks = await measurement_service.task_progress( db, version_id, user.id, production_run_id, ) return TaskProgressListResponse(tasks=tasks) @router.get("/current-cycle", response_model=CurrentCycleResponse) async def get_current_cycle( production_run_id: int = Query(..., gt=0), task_id: int = Query(..., gt=0), user: User = Depends(require_measurement_tec), db: AsyncSession = Depends(get_db), ): """Quotes of this task already measured in the cycle under way. Asked on load, so that a reload mid-cycle does not present quotes again that were taken a moment ago. """ rows = await measurement_service.current_cycle_measurements( db, production_run_id, task_id, ) return CurrentCycleResponse(measurements=rows) @router.post("/{measurement_id}/authorise", response_model=MeasurementResponse) async def authorise_measurement( measurement_id: int, action: MeasurementAuthorisation, user: User = Depends(require_measurement_tec), db: AsyncSession = Depends(get_db), ): """Let an out-of-tolerance value stand, on the capoturno's name. Credentials go to this endpoint rather than to a separate check because the approval has to end up attached to the measurement. A check whose answer is thrown away is what this point had before: a modal that closed. """ result = await db.execute( select(Measurement).where(Measurement.id == measurement_id) ) measurement = result.scalar_one_or_none() if measurement is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Measurement not found", ) if measurement.pass_fail != "fail": raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="This measurement is within tolerance: there is nothing to authorise", ) supervisor = await auth_service.authorise_supervisor( db, action.supervisor_username, action.supervisor_password, user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59 ) measurement = await measurement_service.authorise_measurement( db, measurement, supervisor, ) return MeasurementResponse.model_validate(measurement) @router.post("/batch", response_model=list[MeasurementResponse]) async def create_measurement_batch( data: MeasurementBatchCreate, user: User = Depends(require_measurement_tec), db: AsyncSession = Depends(get_db), ): """Create multiple measurements in a batch.""" measurements = [] try: for measurement_data in data.measurements: measurement = await save_measurement( db=db, subtask_id=measurement_data.subtask_id, version_id=measurement_data.version_id, measured_by=user.id, value=measurement_data.value, lot_number=measurement_data.lot_number, serial_number=measurement_data.serial_number, input_method=measurement_data.input_method, input_duration_ms=measurement_data.input_duration_ms, production_run_id=measurement_data.production_run_id, ) measurements.append(measurement) return [MeasurementResponse.model_validate(m) for m in measurements] except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) def _build_measurement_filters( recipe_id: int | None, version_id: int | None, subtask_id: int | None, measured_by: int | None, lot_number: str | None, serial_number: str | None, date_from: datetime | None, date_to: datetime | None, pass_fail: str | None, production_run_id: int | None = None, ) -> list: """Build SQLAlchemy filter conditions shared by list and CSV export.""" filters = [] if recipe_id is not None: # Filter by recipe via subquery on RecipeVersion version_ids = select(RecipeVersion.id).where( RecipeVersion.recipe_id == recipe_id ) filters.append(Measurement.version_id.in_(version_ids)) if version_id is not None: filters.append(Measurement.version_id == version_id) if subtask_id is not None: filters.append(Measurement.subtask_id == subtask_id) if measured_by is not None: filters.append(Measurement.measured_by == measured_by) if lot_number is not None: filters.append(Measurement.lot_number == lot_number) if serial_number is not None: filters.append(Measurement.serial_number == serial_number) if date_from is not None: filters.append(Measurement.measured_at >= date_from) if date_to is not None: filters.append(Measurement.measured_at <= date_to) if pass_fail is not None: filters.append(Measurement.pass_fail == pass_fail) if production_run_id is not None: filters.append(Measurement.production_run_id == production_run_id) return filters @router.get("/", response_model=MeasurementListResponse) async def get_measurements( recipe_id: int | None = Query(None), version_id: int | None = Query(None), subtask_id: int | None = Query(None), measured_by: int | None = Query(None), lot_number: str | None = Query(None), serial_number: str | None = Query(None), date_from: UtcNaiveInput = Query(None), date_to: UtcNaiveInput = Query(None), pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"), production_run_id: int | None = Query(None, gt=0), page: int = Query(1, ge=1), per_page: int = Query(50, ge=1, le=500), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Query measurements with filters and pagination. Available to any authenticated user. The MeasurementTec workflow needs this endpoint to render `task_complete.html` after running through a recipe; the Metrologist dashboard uses the same endpoint with broader filters. Measurements carry no PII beyond numeric values + a recipe reference, so role-gating beyond authentication isn't justified. """ filters = _build_measurement_filters( recipe_id, version_id, subtask_id, measured_by, lot_number, serial_number, date_from, date_to, pass_fail, production_run_id, ) # Build query query = select(Measurement).where(and_(*filters) if filters else True) # Count total count_query = select(func.count()).select_from(Measurement).where( and_(*filters) if filters else True ) total_result = await db.execute(count_query) total = total_result.scalar_one() # Paginate offset = (page - 1) * per_page query = query.order_by(Measurement.measured_at.desc()).limit(per_page).offset(offset) result = await db.execute(query) measurements = result.scalars().all() pages = (total + per_page - 1) // per_page return MeasurementListResponse( items=[MeasurementResponse.model_validate(m) for m in measurements], total=total, page=page, per_page=per_page, pages=pages, ) @router.get("/export/csv") async def export_measurements_csv( recipe_id: int | None = Query(None), version_id: int | None = Query(None), subtask_id: int | None = Query(None), measured_by: int | None = Query(None), lot_number: str | None = Query(None), serial_number: str | None = Query(None), date_from: UtcNaiveInput = Query(None), date_to: UtcNaiveInput = Query(None), pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Export measurements to CSV with configurable delimiter and decimal separator.""" # Check user has MeasurementTec or Metrologist role, same as require_role: an # admin with no explicit role assigned is still let through (#36 point 3). if not ( user.has_role("MeasurementTec") or user.has_role("Metrologist") or user.is_admin ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="MeasurementTec or Metrologist role required", ) # Get CSV settings from system_settings delimiter_result = await db.execute( select(SystemSetting).where(SystemSetting.setting_key == "csv_delimiter") ) delimiter_setting = delimiter_result.scalar_one_or_none() delimiter = delimiter_setting.setting_value if delimiter_setting else "," decimal_result = await db.execute( select(SystemSetting).where(SystemSetting.setting_key == "csv_decimal_separator") ) decimal_setting = decimal_result.scalar_one_or_none() decimal_separator = decimal_setting.setting_value if decimal_setting else "." filters = _build_measurement_filters( recipe_id, version_id, subtask_id, measured_by, lot_number, serial_number, date_from, date_to, pass_fail, ) # Query all matching measurements query = select(Measurement).where(and_(*filters) if filters else True) query = query.order_by(Measurement.measured_at.desc()) result = await db.execute(query) measurements = result.scalars().all() # Generate CSV output = io.StringIO() writer = csv.writer(output, delimiter=delimiter) # Header writer.writerow([ "id", "subtask_id", "version_id", "measured_by", "value", "pass_fail", "deviation", "lot_number", "serial_number", "input_method", "measured_at", "synced_to_csv", ]) # Rows for m in measurements: # Format decimals with configured separator value_str = str(m.value).replace(".", decimal_separator) deviation_str = ( str(m.deviation).replace(".", decimal_separator) if m.deviation is not None else "" ) writer.writerow([ m.id, m.subtask_id, m.version_id, m.measured_by, value_str, m.pass_fail, deviation_str, neutralise_csv_cell(m.lot_number or ""), neutralise_csv_cell(m.serial_number or ""), m.input_method, iso_utc(m.measured_at), m.synced_to_csv, ]) # Return as streaming response output.seek(0) return StreamingResponse( iter([output.getvalue()]), media_type="text/csv", headers={ "Content-Disposition": f"attachment; filename=measurements_{to_local(utcnow()).strftime('%Y%m%d_%H%M%S')}.csv" }, )