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

@ -2,30 +2,33 @@ from datetime import date
from typing import Optional
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from sqlmodel import Session, SQLModel, select
from db import get_session
from innercontext.api.utils import get_or_404
from innercontext.models import (
Product,
ProductBase,
ProductCategory,
ProductInventory,
ProductPublic,
ProductWithInventory,
SkinConcern,
)
from innercontext.models.enums import (
AbsorptionSpeed,
DayTime,
PriceTier,
SkinType,
TextureType,
)
from innercontext.models.product import (
ActiveIngredient,
ProductContext,
ProductEffectProfile,
ProductInteraction,
)
from innercontext.models.enums import (
AbsorptionSpeed,
DayTime,
PriceTier,
TextureType,
SkinType,
)
router = APIRouter()
@ -35,57 +38,8 @@ router = APIRouter()
# ---------------------------------------------------------------------------
class ProductCreate(SQLModel):
name: str
brand: str
line_name: Optional[str] = None
sku: Optional[str] = None
url: Optional[str] = None
barcode: Optional[str] = None
category: ProductCategory
recommended_time: DayTime
texture: Optional[TextureType] = None
absorption_speed: Optional[AbsorptionSpeed] = None
leave_on: bool
price_tier: Optional[PriceTier] = None
size_ml: Optional[float] = None
pao_months: Optional[int] = None
inci: list[str] = []
actives: Optional[list[ActiveIngredient]] = None
recommended_for: list[SkinType] = []
targets: list[SkinConcern] = []
contraindications: list[str] = []
usage_notes: Optional[str] = None
fragrance_free: Optional[bool] = None
essential_oils_free: Optional[bool] = None
alcohol_denat_free: Optional[bool] = None
pregnancy_safe: Optional[bool] = None
product_effect_profile: ProductEffectProfile = ProductEffectProfile()
ph_min: Optional[float] = None
ph_max: Optional[float] = None
incompatible_with: Optional[list[ProductInteraction]] = None
synergizes_with: Optional[list[str]] = None
context_rules: Optional[ProductContext] = None
min_interval_hours: Optional[int] = None
max_frequency_per_week: Optional[int] = None
is_medication: bool = False
is_tool: bool = False
needle_length_mm: Optional[float] = None
personal_tolerance_notes: Optional[str] = None
personal_repurchase_intent: Optional[bool] = None
class ProductCreate(ProductBase):
pass
class ProductUpdate(SQLModel):
@ -159,17 +113,12 @@ class InventoryUpdate(SQLModel):
notes: Optional[str] = None
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Product routes
# ---------------------------------------------------------------------------
@router.get("", response_model=list[Product])
@router.get("", response_model=list[ProductPublic])
def list_products(
category: Optional[ProductCategory] = None,
brand: Optional[str] = None,
@ -205,7 +154,7 @@ def list_products(
return products
@router.post("", response_model=Product, status_code=201)
@router.post("", response_model=ProductPublic, status_code=201)
def create_product(data: ProductCreate, session: Session = Depends(get_session)):
product = Product(
id=uuid4(),
@ -217,16 +166,18 @@ def create_product(data: ProductCreate, session: Session = Depends(get_session))
return product
@router.get("/{product_id}")
@router.get("/{product_id}", response_model=ProductWithInventory)
def get_product(product_id: UUID, session: Session = Depends(get_session)):
product = get_or_404(session, Product, product_id)
inventory = session.exec(select(ProductInventory).where(ProductInventory.product_id == product_id)).all()
data = product.model_dump(mode="json")
data["inventory"] = [item.model_dump(mode="json") for item in inventory]
return data
inventory = session.exec(
select(ProductInventory).where(ProductInventory.product_id == product_id)
).all()
result = ProductWithInventory.model_validate(product, from_attributes=True)
result.inventory = list(inventory)
return result
@router.patch("/{product_id}", response_model=Product)
@router.patch("/{product_id}", response_model=ProductPublic)
def update_product(
product_id: UUID, data: ProductUpdate, session: Session = Depends(get_session)
):
@ -252,9 +203,7 @@ def delete_product(product_id: UUID, session: Session = Depends(get_session)):
@router.get("/{product_id}/inventory", response_model=list[ProductInventory])
def list_product_inventory(
product_id: UUID, session: Session = Depends(get_session)
):
def list_product_inventory(product_id: UUID, session: Session = Depends(get_session)):
get_or_404(session, Product, product_id)
stmt = select(ProductInventory).where(ProductInventory.product_id == product_id)
return session.exec(stmt).all()