Files
maziggy bf5135cb12 fix(inventory): malformed rgba no longer bricks the Filaments page (#1055)
A single legacy spool with a 7-char rgba ('FFFFFFF', missing one F)
  caused GET /api/v1/inventory/spools to 500 with a pydantic
  ResponseValidationError, leaving the reporter with a blank Filaments
  page and "Add Spool" silently failing. Root cause spans three layers:

  1. Write path: SpoolUpdate.rgba had no pattern constraint (only
     SpoolCreate did), so PATCH could plant malformed values in the DB.
  2. Frontend: ColorSection hex input's `val.length <= 6 ? 'FF' : ''`
     emitted 7-char rgba for 5-char input (XXXXX + FF = 7) and for
     7-char typed input (no alpha appended).
  3. Read path: SpoolResponse inherited the write-side pattern, so a
     single bad row 500'd the entire list endpoint instead of being
     tolerated through serialize.

  SpoolUpdate.rgba now carries the same ^[0-9A-Fa-f]{8}$ pattern as
  SpoolCreate. The hex input emits a fully-formed 8-char RRGGBBAA on
  every keystroke — 8-char paste passes through, 7-char drops the
  stray, shorter input pads RGB with '0' and appends FF alpha.
  SpoolResponse.rgba is now Optional[str] with no pattern — write-side
  validation is the right place for format rules; responses must
  tolerate historical rows.

  Tests: 16 schema tests (SpoolCreate/Update reject, SpoolResponse
  tolerate), 7 frontend tests covering every input length 0–8 plus
  non-hex strip. A user who already has a bad row in their DB now sees
  it render with a default color instead of having to hand-edit SQLite.
2026-04-21 14:40:03 +02:00

129 lines
3.6 KiB
Python

from datetime import datetime
from pydantic import BaseModel, Field
class SpoolBase(BaseModel):
material: str = Field(..., min_length=1, max_length=50)
subtype: str | None = None
color_name: str | None = None
rgba: str | None = Field(None, pattern=r"^[0-9A-Fa-f]{8}$")
brand: str | None = None
label_weight: int = 1000
core_weight: int = 250
core_weight_catalog_id: int | None = None
weight_used: float = 0
slicer_filament: str | None = None
slicer_filament_name: str | None = None
nozzle_temp_min: int | None = None
nozzle_temp_max: int | None = None
note: str | None = None
tag_uid: str | None = None
tray_uuid: str | None = None
data_origin: str | None = None
tag_type: str | None = None
cost_per_kg: float | None = Field(default=None, ge=0)
weight_locked: bool = False
last_scale_weight: int | None = None
last_weighed_at: datetime | None = None
class SpoolCreate(SpoolBase):
pass
class SpoolBulkCreate(BaseModel):
spool: SpoolCreate
quantity: int = Field(default=1, ge=1, le=100)
class SpoolUpdate(BaseModel):
material: str | None = None
subtype: str | None = None
color_name: str | None = None
rgba: str | None = Field(None, pattern=r"^[0-9A-Fa-f]{8}$")
brand: str | None = None
label_weight: int | None = None
core_weight: int | None = None
core_weight_catalog_id: int | None = None
weight_used: float | None = None
slicer_filament: str | None = None
slicer_filament_name: str | None = None
nozzle_temp_min: int | None = None
nozzle_temp_max: int | None = None
note: str | None = None
tag_uid: str | None = None
tray_uuid: str | None = None
data_origin: str | None = None
tag_type: str | None = None
cost_per_kg: float | None = Field(default=None, ge=0)
weight_locked: bool | None = None
class SpoolKProfileBase(BaseModel):
printer_id: int
extruder: int = 0
nozzle_diameter: str = "0.4"
nozzle_type: str | None = None
k_value: float
name: str | None = None
cali_idx: int | None = None
setting_id: str | None = None
class SpoolKProfileResponse(SpoolKProfileBase):
id: int
spool_id: int
created_at: datetime
class Config:
from_attributes = True
class SpoolResponse(SpoolBase):
id: int
# rgba is intentionally unconstrained on the response side: the write paths
# (SpoolCreate, SpoolUpdate) enforce the 8-char hex pattern, but legacy rows
# or data sourced from AMS firmware / backups may carry malformed values.
# A single bad row must not 500 the entire inventory list endpoint (#1055).
rgba: str | None = None
added_full: bool | None = None
last_used: datetime | None = None
encode_time: datetime | None = None
tag_uid: str | None = None
tray_uuid: str | None = None
data_origin: str | None = None
tag_type: str | None = None
archived_at: datetime | None = None
created_at: datetime
updated_at: datetime
k_profiles: list[SpoolKProfileResponse] = []
class Config:
from_attributes = True
class SpoolAssignmentCreate(BaseModel):
spool_id: int
printer_id: int
ams_id: int
tray_id: int
class SpoolAssignmentResponse(BaseModel):
id: int
spool_id: int
printer_id: int
printer_name: str | None = None
ams_id: int
tray_id: int
fingerprint_color: str | None = None
fingerprint_type: str | None = None
created_at: datetime
spool: SpoolResponse | None = None
configured: bool = False
ams_label: str | None = None # User-defined friendly name for the AMS unit
class Config:
from_attributes = True