refactor: split table models into Base/Table/Public for proper FastAPI serialization
Add ProductBase, ProductPublic, ProductWithInventory and SkinConditionSnapshotBase, SkinConditionSnapshotPublic. Table models now inherit from their Base counterpart and override JSON fields with sa_column. All field_serializer hacks removed; FastAPI response models use the non-table Public classes so Pydantic coerces raw DB dicts → typed models cleanly. ProductCreate and SnapshotCreate now simply inherit their respective Base classes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
479be25112
commit
c09acc7c81
15 changed files with 225 additions and 198 deletions
|
|
@ -21,7 +21,6 @@ from .enums import (
|
|||
TextureType,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Value objects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -83,16 +82,11 @@ def _ev(v: object) -> str:
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Table models
|
||||
# Base model (pure Python types, no sa_column, no id/timestamps)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Product(SQLModel, table=True):
|
||||
__tablename__ = "products"
|
||||
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
|
||||
class ProductBase(SQLModel):
|
||||
name: str
|
||||
brand: str
|
||||
line_name: str | None = Field(default=None, max_length=128)
|
||||
|
|
@ -107,10 +101,61 @@ class Product(SQLModel, table=True):
|
|||
absorption_speed: AbsorptionSpeed | None = None
|
||||
leave_on: bool
|
||||
|
||||
price_tier: PriceTier | None = Field(default=None, index=True)
|
||||
price_tier: PriceTier | None = None
|
||||
size_ml: float | None = Field(default=None, gt=0)
|
||||
pao_months: int | None = Field(default=None, ge=1, le=60)
|
||||
|
||||
inci: list[str] = Field(default_factory=list)
|
||||
actives: list[ActiveIngredient] | None = None
|
||||
|
||||
recommended_for: list[SkinType] = Field(default_factory=list)
|
||||
|
||||
targets: list[SkinConcern] = Field(default_factory=list)
|
||||
contraindications: list[str] = Field(default_factory=list)
|
||||
usage_notes: str | None = None
|
||||
|
||||
fragrance_free: bool | None = None
|
||||
essential_oils_free: bool | None = None
|
||||
alcohol_denat_free: bool | None = None
|
||||
pregnancy_safe: bool | None = None
|
||||
|
||||
product_effect_profile: ProductEffectProfile = Field(
|
||||
default_factory=ProductEffectProfile
|
||||
)
|
||||
|
||||
ph_min: float | None = Field(default=None, ge=0, le=14)
|
||||
ph_max: float | None = Field(default=None, ge=0, le=14)
|
||||
|
||||
incompatible_with: list[ProductInteraction] | None = None
|
||||
synergizes_with: list[str] | None = None
|
||||
context_rules: ProductContext | None = None
|
||||
|
||||
min_interval_hours: int | None = Field(default=None, ge=0)
|
||||
max_frequency_per_week: int | None = Field(default=None, ge=1, le=14)
|
||||
|
||||
is_medication: bool = Field(default=False)
|
||||
is_tool: bool = Field(default=False)
|
||||
needle_length_mm: float | None = Field(default=None, gt=0)
|
||||
|
||||
personal_tolerance_notes: str | None = None
|
||||
personal_repurchase_intent: bool | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Table models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Product(ProductBase, table=True):
|
||||
__tablename__ = "products"
|
||||
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
|
||||
# Override: add index for table context
|
||||
price_tier: PriceTier | None = Field(default=None, index=True)
|
||||
|
||||
# Override 9 JSON fields with sa_column (only in table model)
|
||||
inci: list[str] = Field(
|
||||
default_factory=list, sa_column=Column(JSON, nullable=False)
|
||||
)
|
||||
|
|
@ -128,21 +173,12 @@ class Product(SQLModel, table=True):
|
|||
contraindications: list[str] = Field(
|
||||
default_factory=list, sa_column=Column(JSON, nullable=False)
|
||||
)
|
||||
usage_notes: str | None = None
|
||||
|
||||
fragrance_free: bool | None = None
|
||||
essential_oils_free: bool | None = None
|
||||
alcohol_denat_free: bool | None = None
|
||||
pregnancy_safe: bool | None = None
|
||||
|
||||
product_effect_profile: ProductEffectProfile = Field(
|
||||
default_factory=ProductEffectProfile,
|
||||
sa_column=Column(JSON, nullable=False),
|
||||
)
|
||||
|
||||
ph_min: float | None = Field(default=None, ge=0, le=14)
|
||||
ph_max: float | None = Field(default=None, ge=0, le=14)
|
||||
|
||||
incompatible_with: list[ProductInteraction] | None = Field(
|
||||
default=None, sa_column=Column(JSON, nullable=True)
|
||||
)
|
||||
|
|
@ -153,16 +189,6 @@ class Product(SQLModel, table=True):
|
|||
default=None, sa_column=Column(JSON, nullable=True)
|
||||
)
|
||||
|
||||
min_interval_hours: int | None = Field(default=None, ge=0)
|
||||
max_frequency_per_week: int | None = Field(default=None, ge=1, le=14)
|
||||
|
||||
is_medication: bool = Field(default=False)
|
||||
is_tool: bool = Field(default=False)
|
||||
needle_length_mm: float | None = Field(default=None, gt=0)
|
||||
|
||||
personal_tolerance_notes: str | None = None
|
||||
personal_repurchase_intent: bool | None = None
|
||||
|
||||
created_at: datetime = Field(default_factory=utc_now, nullable=False)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=utc_now,
|
||||
|
|
@ -264,13 +290,12 @@ class Product(SQLModel, table=True):
|
|||
ctx["ph_max"] = self.ph_max
|
||||
|
||||
ep = self.product_effect_profile
|
||||
if ep is not None:
|
||||
if isinstance(ep, dict):
|
||||
nonzero = {k: v for k, v in ep.items() if v >= 2}
|
||||
else:
|
||||
nonzero = {k: v for k, v in ep.model_dump().items() if v >= 2}
|
||||
if nonzero:
|
||||
ctx["effect_profile"] = nonzero
|
||||
if isinstance(ep, dict):
|
||||
nonzero = {k: v for k, v in ep.items() if v >= 2}
|
||||
else:
|
||||
nonzero = {k: v for k, v in ep.model_dump().items() if v >= 2}
|
||||
if nonzero:
|
||||
ctx["effect_profile"] = nonzero
|
||||
|
||||
if self.incompatible_with:
|
||||
parts = []
|
||||
|
|
@ -307,7 +332,12 @@ class Product(SQLModel, table=True):
|
|||
ctx["max_frequency_per_week"] = self.max_frequency_per_week
|
||||
|
||||
safety = {}
|
||||
for flag in ("fragrance_free", "essential_oils_free", "alcohol_denat_free", "pregnancy_safe"):
|
||||
for flag in (
|
||||
"fragrance_free",
|
||||
"essential_oils_free",
|
||||
"alcohol_denat_free",
|
||||
"pregnancy_safe",
|
||||
):
|
||||
val = getattr(self, flag)
|
||||
if val is not None:
|
||||
safety[flag] = val
|
||||
|
|
@ -358,3 +388,18 @@ class ProductInventory(SQLModel, table=True):
|
|||
created_at: datetime = Field(default_factory=utc_now, nullable=False)
|
||||
|
||||
product: Optional["Product"] = Relationship(back_populates="inventory")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProductPublic(ProductBase):
|
||||
id: UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ProductWithInventory(ProductPublic):
|
||||
inventory: list[ProductInventory] = []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue