7b015078e9
#20 - models/api/measurement.py: value accettava NaN/Infinity/fuori scala, ogni confronto tornava falso ("pass") e poi l'INSERT falliva. Il controllo sposta in measurement_service._validate_measured_value (math.isfinite + |value|<1e6): un Field pydantic (allow_inf_nan/gt/lt) rifiuta la stessa cosa ma FastAPI, costruendo l'errore 422, riecheggia il NaN nel campo "input" e Starlette lo nega con allow_nan=False, producendo un 500 al posto del 422 - verificato con una chiamata reale, non solo a livello di modello. calculate_pass_fail e save_measurement arrotondano value a 6 decimali (stessa scala di DECIMAL(12,6)) prima di calcolare e salvare, cosi' il verdetto e il valore persistito non divergono mai. #5 - measurement_service.save_measurement: prima dell'insert verifica che la quota appartenga a un task della version_id dichiarata (422 altrimenti), e se production_run_id e' dato usa la nuova validate_run_for_version(db, run_id, version_id): il run deve esistere (404), essere della stessa ricetta della versione (422), essere misurabile - riusa production_service._require_measurable (409 se chiuso o in pausa). Import locale per evitare un ciclo con production_service, che gia' importa measurement_service. Test: gia' presenti in test_measurements.py (TestMeasurementValueBounds, TestSaveMeasurementCoherence) - finiti nel commit0dce229di un altro agente che lavorava in parallelo sullo stesso file (export CSV, #22); confermato con `git show0dce229-- test_measurements.py`, contenuto intatto. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
107 lines
3.8 KiB
Python
107 lines
3.8 KiB
Python
"""Pydantic schemas for Measurement operations."""
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
|
|
class MeasurementCreate(BaseModel):
|
|
"""Schema for creating a single measurement."""
|
|
subtask_id: int
|
|
version_id: int
|
|
# Finiteness and the DECIMAL(12,6) scale bound are enforced in
|
|
# measurement_service.save_measurement, not with a Field constraint here:
|
|
# a NaN/Infinity that fails a pydantic Field constraint gets echoed back
|
|
# in the validation error's "input", and FastAPI's default error response
|
|
# then crashes with a 500 trying to JSON-encode that same NaN/Infinity
|
|
# (json.dumps(allow_nan=False) in Starlette's JSONResponse). A plain
|
|
# HTTPException raised after parsing sidesteps that.
|
|
value: float
|
|
lot_number: Optional[str] = Field(None, max_length=100)
|
|
serial_number: Optional[str] = Field(None, max_length=100)
|
|
# "camera" is deliberately absent from this pattern, even though the ORM
|
|
# enum and the database allow it. Do not add it here: a camera
|
|
# measurement must be produced by vision_service.execute_task, never by a
|
|
# client posting JSON straight to this endpoint - there is no image, no
|
|
# graph and no engine_version behind a value that arrived this way.
|
|
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
|
|
input_duration_ms: Optional[int] = Field(None, ge=0)
|
|
# The production this belongs to, when one is open at the station.
|
|
production_run_id: Optional[int] = Field(None, gt=0)
|
|
|
|
|
|
class MeasurementBatchCreate(BaseModel):
|
|
"""Schema for creating multiple measurements at once."""
|
|
measurements: list[MeasurementCreate]
|
|
|
|
|
|
class MeasurementResponse(BaseModel):
|
|
"""Schema for measurement response."""
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: int
|
|
subtask_id: int
|
|
version_id: int
|
|
measured_by: int
|
|
value: float
|
|
pass_fail: str
|
|
deviation: Optional[float] = None
|
|
lot_number: Optional[str] = None
|
|
serial_number: Optional[str] = None
|
|
input_method: str
|
|
input_duration_ms: Optional[int] = None
|
|
production_run_id: Optional[int] = None
|
|
# Who let this value stand, when it was out of tolerance. Null on a value in
|
|
# tolerance - and on one that is not and is still waiting for an answer.
|
|
supervisor_id: Optional[int] = None
|
|
authorised_at: Optional[datetime] = None
|
|
measured_at: datetime
|
|
synced_to_csv: bool
|
|
|
|
|
|
class MeasurementListResponse(BaseModel):
|
|
"""Schema for paginated measurement list."""
|
|
items: list[MeasurementResponse]
|
|
total: int
|
|
page: int
|
|
per_page: int
|
|
pages: int
|
|
|
|
|
|
class MeasurementAuthorisation(BaseModel):
|
|
"""Credentials of the supervisor allowing an out-of-tolerance value to stand."""
|
|
|
|
supervisor_username: str = Field(..., min_length=1)
|
|
supervisor_password: str = Field(..., min_length=1)
|
|
|
|
|
|
class TaskProgressResponse(BaseModel):
|
|
"""How far one measurement task has got: quotes taken out of quotes expected."""
|
|
|
|
task_id: int
|
|
quotes: int
|
|
measured: int
|
|
# empty (declared as measurement, no quotes yet) | none | partial | complete
|
|
state: str
|
|
|
|
|
|
class TaskProgressListResponse(BaseModel):
|
|
"""Progress of every measurement task of a version."""
|
|
|
|
tasks: list[TaskProgressResponse]
|
|
|
|
|
|
class MeasurementQuery(BaseModel):
|
|
"""Schema for measurement query filters."""
|
|
recipe_id: Optional[int] = None
|
|
version_id: Optional[int] = None
|
|
subtask_id: Optional[int] = None
|
|
measured_by: Optional[int] = None
|
|
lot_number: Optional[str] = None
|
|
serial_number: Optional[str] = None
|
|
date_from: Optional[datetime] = None
|
|
date_to: Optional[datetime] = None
|
|
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
|
|
page: int = Field(1, ge=1)
|
|
per_page: int = Field(50, ge=1, le=500)
|