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
|
|
@ -3,7 +3,7 @@ from datetime import datetime
|
|||
from typing import Optional
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import field_validator
|
||||
from sqlmodel import Session, SQLModel, col, select
|
||||
|
||||
|
|
@ -72,8 +72,11 @@ class LabResultCreate(SQLModel):
|
|||
@classmethod
|
||||
def check_test_code_format(cls, v: str) -> str:
|
||||
if not re.fullmatch(r"\d+-\d", v):
|
||||
raise ValueError("test_code must match LOINC format: digits-digit (e.g. 718-7)")
|
||||
raise ValueError(
|
||||
"test_code must match LOINC format: digits-digit (e.g. 718-7)"
|
||||
)
|
||||
return v
|
||||
|
||||
test_name_original: Optional[str] = None
|
||||
test_name_loinc: Optional[str] = None
|
||||
|
||||
|
|
@ -142,9 +145,7 @@ def list_medications(
|
|||
|
||||
|
||||
@router.post("/medications", response_model=MedicationEntry, status_code=201)
|
||||
def create_medication(
|
||||
data: MedicationCreate, session: Session = Depends(get_session)
|
||||
):
|
||||
def create_medication(data: MedicationCreate, session: Session = Depends(get_session)):
|
||||
entry = MedicationEntry(record_id=uuid4(), **data.model_dump())
|
||||
session.add(entry)
|
||||
session.commit()
|
||||
|
|
@ -274,9 +275,7 @@ def list_lab_results(
|
|||
|
||||
|
||||
@router.post("/lab-results", response_model=LabResult, status_code=201)
|
||||
def create_lab_result(
|
||||
data: LabResultCreate, session: Session = Depends(get_session)
|
||||
):
|
||||
def create_lab_result(data: LabResultCreate, session: Session = Depends(get_session)):
|
||||
result = LabResult(record_id=uuid4(), **data.model_dump())
|
||||
session.add(result)
|
||||
session.commit()
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from db import get_session
|
||||
from innercontext.api.products import InventoryUpdate
|
||||
from innercontext.api.utils import get_or_404
|
||||
from innercontext.models import ProductInventory
|
||||
from innercontext.api.products import InventoryUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from datetime import date
|
|||
from typing import Optional
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session, SQLModel, select
|
||||
|
||||
from db import get_session
|
||||
|
|
@ -105,7 +105,9 @@ def list_grooming_schedule(session: Session = Depends(get_session)):
|
|||
@router.get("/{routine_id}")
|
||||
def get_routine(routine_id: UUID, session: Session = Depends(get_session)):
|
||||
routine = get_or_404(session, Routine, routine_id)
|
||||
steps = session.exec(select(RoutineStep).where(RoutineStep.routine_id == routine_id)).all()
|
||||
steps = session.exec(
|
||||
select(RoutineStep).where(RoutineStep.routine_id == routine_id)
|
||||
).all()
|
||||
data = routine.model_dump(mode="json")
|
||||
data["steps"] = [step.model_dump(mode="json") for step in steps]
|
||||
return data
|
||||
|
|
@ -206,9 +208,7 @@ def update_grooming_schedule(
|
|||
|
||||
|
||||
@router.delete("/grooming-schedule/{entry_id}", status_code=204)
|
||||
def delete_grooming_schedule(
|
||||
entry_id: UUID, session: Session = Depends(get_session)
|
||||
):
|
||||
def delete_grooming_schedule(entry_id: UUID, session: Session = Depends(get_session)):
|
||||
entry = get_or_404(session, GroomingSchedule, entry_id)
|
||||
session.delete(entry)
|
||||
session.commit()
|
||||
|
|
|
|||
|
|
@ -2,12 +2,16 @@ from datetime import date
|
|||
from typing import Optional
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session, SQLModel, select
|
||||
|
||||
from db import get_session
|
||||
from innercontext.api.utils import get_or_404
|
||||
from innercontext.models import SkinConditionSnapshot
|
||||
from innercontext.models import (
|
||||
SkinConditionSnapshot,
|
||||
SkinConditionSnapshotBase,
|
||||
SkinConditionSnapshotPublic,
|
||||
)
|
||||
from innercontext.models.enums import (
|
||||
BarrierState,
|
||||
OverallSkinState,
|
||||
|
|
@ -24,23 +28,8 @@ router = APIRouter()
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SnapshotCreate(SQLModel):
|
||||
snapshot_date: date
|
||||
overall_state: Optional[OverallSkinState] = None
|
||||
trend: Optional[SkinTrend] = None
|
||||
skin_type: Optional[SkinType] = None
|
||||
|
||||
hydration_level: Optional[int] = None
|
||||
sebum_tzone: Optional[int] = None
|
||||
sebum_cheeks: Optional[int] = None
|
||||
sensitivity_level: Optional[int] = None
|
||||
|
||||
barrier_state: Optional[BarrierState] = None
|
||||
|
||||
active_concerns: list[SkinConcern] = []
|
||||
risks: list[str] = []
|
||||
priorities: list[str] = []
|
||||
notes: Optional[str] = None
|
||||
class SnapshotCreate(SkinConditionSnapshotBase):
|
||||
pass
|
||||
|
||||
|
||||
class SnapshotUpdate(SQLModel):
|
||||
|
|
@ -62,17 +51,12 @@ class SnapshotUpdate(SQLModel):
|
|||
notes: Optional[str] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("", response_model=list[SkinConditionSnapshot])
|
||||
@router.get("", response_model=list[SkinConditionSnapshotPublic])
|
||||
def list_snapshots(
|
||||
from_date: Optional[date] = None,
|
||||
to_date: Optional[date] = None,
|
||||
|
|
@ -89,10 +73,8 @@ def list_snapshots(
|
|||
return session.exec(stmt).all()
|
||||
|
||||
|
||||
@router.post("", response_model=SkinConditionSnapshot, status_code=201)
|
||||
def create_snapshot(
|
||||
data: SnapshotCreate, session: Session = Depends(get_session)
|
||||
):
|
||||
@router.post("", response_model=SkinConditionSnapshotPublic, status_code=201)
|
||||
def create_snapshot(data: SnapshotCreate, session: Session = Depends(get_session)):
|
||||
snapshot = SkinConditionSnapshot(id=uuid4(), **data.model_dump())
|
||||
session.add(snapshot)
|
||||
session.commit()
|
||||
|
|
@ -100,12 +82,12 @@ def create_snapshot(
|
|||
return snapshot
|
||||
|
||||
|
||||
@router.get("/{snapshot_id}", response_model=SkinConditionSnapshot)
|
||||
@router.get("/{snapshot_id}", response_model=SkinConditionSnapshotPublic)
|
||||
def get_snapshot(snapshot_id: UUID, session: Session = Depends(get_session)):
|
||||
return get_or_404(session, SkinConditionSnapshot, snapshot_id)
|
||||
|
||||
|
||||
@router.patch("/{snapshot_id}", response_model=SkinConditionSnapshot)
|
||||
@router.patch("/{snapshot_id}", response_model=SkinConditionSnapshotPublic)
|
||||
def update_snapshot(
|
||||
snapshot_id: UUID,
|
||||
data: SnapshotUpdate,
|
||||
|
|
|
|||
|
|
@ -25,13 +25,20 @@ from .health import LabResult, MedicationEntry, MedicationUsage
|
|||
from .product import (
|
||||
ActiveIngredient,
|
||||
Product,
|
||||
ProductBase,
|
||||
ProductContext,
|
||||
ProductEffectProfile,
|
||||
ProductInteraction,
|
||||
ProductInventory,
|
||||
ProductPublic,
|
||||
ProductWithInventory,
|
||||
)
|
||||
from .routine import GroomingSchedule, Routine, RoutineStep
|
||||
from .skincare import SkinConditionSnapshot
|
||||
from .skincare import (
|
||||
SkinConditionSnapshot,
|
||||
SkinConditionSnapshotBase,
|
||||
SkinConditionSnapshotPublic,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# domain
|
||||
|
|
@ -64,14 +71,19 @@ __all__ = [
|
|||
# product
|
||||
"ActiveIngredient",
|
||||
"Product",
|
||||
"ProductBase",
|
||||
"ProductContext",
|
||||
"ProductEffectProfile",
|
||||
"ProductInteraction",
|
||||
"ProductInventory",
|
||||
"ProductPublic",
|
||||
"ProductWithInventory",
|
||||
# routine
|
||||
"GroomingSchedule",
|
||||
"Routine",
|
||||
"RoutineStep",
|
||||
# skincare
|
||||
"SkinConditionSnapshot",
|
||||
"SkinConditionSnapshotBase",
|
||||
"SkinConditionSnapshotPublic",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from enum import Enum
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared / Product
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,7 +290,6 @@ 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:
|
||||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -11,19 +11,13 @@ from .base import utc_now
|
|||
from .domain import Domain
|
||||
from .enums import BarrierState, OverallSkinState, SkinConcern, SkinTrend, SkinType
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base model (pure Python types, no sa_column, no id/created_at)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SkinConditionSnapshot(SQLModel, table=True):
|
||||
"""
|
||||
Tygodniowy snapshot kondycji skóry wypełniany przez LLM na podstawie zdjęć
|
||||
i kontekstu rutyny. Wszystkie metryki numeryczne w skali 1–5.
|
||||
"""
|
||||
|
||||
__tablename__ = "skin_condition_snapshots"
|
||||
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
|
||||
__table_args__ = (UniqueConstraint("snapshot_date", name="uq_skin_snapshot_date"),)
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
snapshot_date: date = Field(index=True)
|
||||
class SkinConditionSnapshotBase(SQLModel):
|
||||
snapshot_date: date
|
||||
|
||||
overall_state: OverallSkinState | None = None
|
||||
trend: SkinTrend | None = None
|
||||
|
|
@ -38,17 +32,53 @@ class SkinConditionSnapshot(SQLModel, table=True):
|
|||
barrier_state: BarrierState | None = None
|
||||
|
||||
# Aktywne troski — podzbiór SkinConcern widoczny na zdjęciach lub wynikający z rutyny
|
||||
active_concerns: list[SkinConcern] = Field(default_factory=list)
|
||||
|
||||
# Wolny tekst — LLM wypełnia na podstawie analizy
|
||||
risks: list[str] = Field(default_factory=list)
|
||||
priorities: list[str] = Field(default_factory=list)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Table model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SkinConditionSnapshot(SkinConditionSnapshotBase, table=True):
|
||||
"""
|
||||
Tygodniowy snapshot kondycji skóry wypełniany przez LLM na podstawie zdjęć
|
||||
i kontekstu rutyny. Wszystkie metryki numeryczne w skali 1–5.
|
||||
"""
|
||||
|
||||
__tablename__ = "skin_condition_snapshots"
|
||||
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
|
||||
__table_args__ = (UniqueConstraint("snapshot_date", name="uq_skin_snapshot_date"),)
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
|
||||
# Override: add index for table context
|
||||
snapshot_date: date = Field(index=True)
|
||||
|
||||
# Override 3 JSON fields with sa_column (only in table model)
|
||||
active_concerns: list[SkinConcern] = Field(
|
||||
default_factory=list, sa_column=Column(JSON, nullable=False)
|
||||
)
|
||||
|
||||
# Wolny tekst — LLM wypełnia na podstawie analizy
|
||||
risks: list[str] = Field(
|
||||
default_factory=list, sa_column=Column(JSON, nullable=False)
|
||||
)
|
||||
priorities: list[str] = Field(
|
||||
default_factory=list, sa_column=Column(JSON, nullable=False)
|
||||
)
|
||||
notes: str | None = None
|
||||
|
||||
created_at: datetime = Field(default_factory=utc_now, nullable=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public response model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SkinConditionSnapshotPublic(SkinConditionSnapshotBase):
|
||||
id: UUID
|
||||
created_at: datetime
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ from dotenv import load_dotenv
|
|||
|
||||
load_dotenv() # load .env before db.py reads DATABASE_URL
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
|
||||
|
||||
from db import create_db_and_tables
|
||||
from innercontext.api import health, inventory, products, routines, skincare
|
||||
from db import create_db_and_tables # noqa: E402
|
||||
from innercontext.api import health, inventory, products, routines, skincare # noqa: E402
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import uuid
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Medications
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -32,9 +31,7 @@ def test_list_filter_kind(client):
|
|||
client.post(
|
||||
"/health/medications", json={"kind": "prescription", "product_name": "A"}
|
||||
)
|
||||
client.post(
|
||||
"/health/medications", json={"kind": "supplement", "product_name": "B"}
|
||||
)
|
||||
client.post("/health/medications", json={"kind": "supplement", "product_name": "B"})
|
||||
r = client.get("/health/medications?kind=supplement")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
|
|
@ -46,9 +43,7 @@ def test_list_filter_product_name(client):
|
|||
client.post(
|
||||
"/health/medications", json={"kind": "otc", "product_name": "Epiduo Forte"}
|
||||
)
|
||||
client.post(
|
||||
"/health/medications", json={"kind": "otc", "product_name": "Panoxyl"}
|
||||
)
|
||||
client.post("/health/medications", json={"kind": "otc", "product_name": "Panoxyl"})
|
||||
r = client.get("/health/medications?product_name=epid")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
|
|
@ -113,7 +108,11 @@ def test_create_usage(client, created_medication):
|
|||
mid = created_medication["record_id"]
|
||||
r = client.post(
|
||||
f"/health/medications/{mid}/usages",
|
||||
json={"valid_from": "2026-01-01T08:00:00", "dose_value": 1.0, "dose_unit": "pea"},
|
||||
json={
|
||||
"valid_from": "2026-01-01T08:00:00",
|
||||
"dose_value": 1.0,
|
||||
"dose_unit": "pea",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
data = r.json()
|
||||
|
|
@ -156,7 +155,9 @@ def test_update_usage(client, created_medication):
|
|||
)
|
||||
uid = r.json()["record_id"]
|
||||
|
||||
r2 = client.patch(f"/health/usages/{uid}", json={"dose_value": 2.5, "dose_unit": "mg"})
|
||||
r2 = client.patch(
|
||||
f"/health/usages/{uid}", json={"dose_value": 2.5, "dose_unit": "mg"}
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["dose_value"] == 2.5
|
||||
assert r2.json()["dose_unit"] == "mg"
|
||||
|
|
@ -247,8 +248,14 @@ def test_list_filter_flag(client):
|
|||
|
||||
|
||||
def test_list_filter_date_range(client):
|
||||
client.post("/health/lab-results", json={**LAB_RESULT_DATA, "collected_at": "2026-01-01T00:00:00"})
|
||||
client.post("/health/lab-results", json={**LAB_RESULT_DATA, "collected_at": "2026-06-01T00:00:00"})
|
||||
client.post(
|
||||
"/health/lab-results",
|
||||
json={**LAB_RESULT_DATA, "collected_at": "2026-01-01T00:00:00"},
|
||||
)
|
||||
client.post(
|
||||
"/health/lab-results",
|
||||
json={**LAB_RESULT_DATA, "collected_at": "2026-06-01T00:00:00"},
|
||||
)
|
||||
r = client.get("/health/lab-results?from_date=2026-05-01T00:00:00")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
|
|
@ -271,7 +278,9 @@ def test_get_lab_result_not_found(client):
|
|||
def test_update_lab_result(client):
|
||||
r = client.post("/health/lab-results", json=LAB_RESULT_DATA)
|
||||
rid = r.json()["record_id"]
|
||||
r2 = client.patch(f"/health/lab-results/{rid}", json={"notes": "Recheck in 3 months"})
|
||||
r2 = client.patch(
|
||||
f"/health/lab-results/{rid}", json={"notes": "Recheck in 3 months"}
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["notes"] == "Recheck in 3 months"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
"""Unit tests for Product.to_llm_context() — no database required."""
|
||||
from uuid import uuid4
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
|
||||
from innercontext.models import Product
|
||||
from innercontext.models.enums import (
|
||||
|
|
|
|||
|
|
@ -55,7 +55,9 @@ def test_list_filter_category(client, client_and_data=None):
|
|||
"recommended_time": "both",
|
||||
"leave_on": True,
|
||||
}
|
||||
r1 = client.post("/products", json={**base, "name": "Moist", "category": "moisturizer"})
|
||||
r1 = client.post(
|
||||
"/products", json={**base, "name": "Moist", "category": "moisturizer"}
|
||||
)
|
||||
r2 = client.post("/products", json={**base, "name": "Ser", "category": "serum"})
|
||||
assert r1.status_code == 201
|
||||
assert r2.status_code == 201
|
||||
|
|
@ -96,7 +98,12 @@ def test_list_filter_is_medication(client):
|
|||
# is_medication=True requires usage_notes (model validator)
|
||||
client.post(
|
||||
"/products",
|
||||
json={**base, "name": "Med", "is_medication": True, "usage_notes": "Apply pea-sized amount"},
|
||||
json={
|
||||
**base,
|
||||
"name": "Med",
|
||||
"is_medication": True,
|
||||
"usage_notes": "Apply pea-sized amount",
|
||||
},
|
||||
)
|
||||
|
||||
r = client.get("/products?is_medication=true")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import uuid
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -112,9 +112,7 @@ def test_update_snapshot_concerns(client):
|
|||
|
||||
|
||||
def test_update_snapshot_not_found(client):
|
||||
r = client.patch(
|
||||
f"/skincare/{uuid.uuid4()}", json={"overall_state": "good"}
|
||||
)
|
||||
r = client.patch(f"/skincare/{uuid.uuid4()}", json={"overall_state": "good"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue