Files
TieMeasureFlow/src/backend/api/routers/measurements.py
T
Adriano Dal Pastro fd571c479e fix: code review — security fixes, dedup, cleanup
- setup: validate roles against users.VALID_ROLES (Supervisor was missing)
- files: fix path traversal prefix-match edge case (is_relative_to),
  dedupe path validation into resolve_upload_path(), use logging not print
- measurements: extract shared _build_measurement_filters() helper
- client app: prevent open redirect via Referer on /set-language
- maker: guard resp.json() in parse-technical-sheet proxy
- measure/maker: extract shared file proxy into services/file_proxy.py
- measure: localize supervisor validation error messages
- annotation-editor: remove global keydown listener in destroy()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:16:29 +00:00

282 lines
9.6 KiB
Python

"""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 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 (
MeasurementBatchCreate,
MeasurementCreate,
MeasurementListResponse,
MeasurementResponse,
)
from src.backend.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),
)
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,
) -> 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)
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: 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(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,
)
# 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 "."
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,
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"
},
)