"""Measurements router - create, query, export measurements.""" import csv import io from datetime import datetime 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 database import get_db from middleware.api_key import ( get_current_user, require_measurement_tec, require_metrologist, ) from models.measurement import Measurement from models.recipe import RecipeVersion from models.setting import SystemSetting from models.user import User from schemas.measurement import ( MeasurementBatchCreate, MeasurementCreate, MeasurementListResponse, MeasurementResponse, ) from services.measurement_service import save_measurement 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, ) return MeasurementResponse.model_validate(measurement) except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) @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, ) 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), ) @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: datetime | None = Query(None), date_to: datetime | None = Query(None), pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"), page: int = Query(1, ge=1), per_page: int = Query(50, ge=1, le=500), user: User = Depends(require_metrologist), db: AsyncSession = Depends(get_db), ): """Query measurements with filters and pagination.""" # Build filter conditions 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) # 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: datetime | None = Query(None), date_to: datetime | None = 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 if not (user.has_role("MeasurementTec") or user.has_role("Metrologist")): 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 "." # Build filter conditions (same as get_measurements) filters = [] if recipe_id is not None: 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) # 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, m.lot_number or "", m.serial_number or "", m.input_method, m.measured_at.isoformat(), 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_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" }, )