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:
Piotr Oleszczyk 2026-02-27 15:37:46 +01:00
parent 479be25112
commit c09acc7c81
15 changed files with 225 additions and 198 deletions

View file

@ -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] = []