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

@ -3,7 +3,7 @@ from datetime import datetime
from typing import Optional from typing import Optional
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from pydantic import field_validator from pydantic import field_validator
from sqlmodel import Session, SQLModel, col, select from sqlmodel import Session, SQLModel, col, select
@ -72,8 +72,11 @@ class LabResultCreate(SQLModel):
@classmethod @classmethod
def check_test_code_format(cls, v: str) -> str: def check_test_code_format(cls, v: str) -> str:
if not re.fullmatch(r"\d+-\d", v): 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 return v
test_name_original: Optional[str] = None test_name_original: Optional[str] = None
test_name_loinc: 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) @router.post("/medications", response_model=MedicationEntry, status_code=201)
def create_medication( def create_medication(data: MedicationCreate, session: Session = Depends(get_session)):
data: MedicationCreate, session: Session = Depends(get_session)
):
entry = MedicationEntry(record_id=uuid4(), **data.model_dump()) entry = MedicationEntry(record_id=uuid4(), **data.model_dump())
session.add(entry) session.add(entry)
session.commit() session.commit()
@ -274,9 +275,7 @@ def list_lab_results(
@router.post("/lab-results", response_model=LabResult, status_code=201) @router.post("/lab-results", response_model=LabResult, status_code=201)
def create_lab_result( def create_lab_result(data: LabResultCreate, session: Session = Depends(get_session)):
data: LabResultCreate, session: Session = Depends(get_session)
):
result = LabResult(record_id=uuid4(), **data.model_dump()) result = LabResult(record_id=uuid4(), **data.model_dump())
session.add(result) session.add(result)
session.commit() session.commit()

View file

@ -1,12 +1,12 @@
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from sqlmodel import Session from sqlmodel import Session
from db import get_session from db import get_session
from innercontext.api.products import InventoryUpdate
from innercontext.api.utils import get_or_404 from innercontext.api.utils import get_or_404
from innercontext.models import ProductInventory from innercontext.models import ProductInventory
from innercontext.api.products import InventoryUpdate
router = APIRouter() router = APIRouter()

View file

@ -2,30 +2,33 @@ from datetime import date
from typing import Optional from typing import Optional
from uuid import UUID, uuid4 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 sqlmodel import Session, SQLModel, select
from db import get_session from db import get_session
from innercontext.api.utils import get_or_404 from innercontext.api.utils import get_or_404
from innercontext.models import ( from innercontext.models import (
Product, Product,
ProductBase,
ProductCategory, ProductCategory,
ProductInventory, ProductInventory,
ProductPublic,
ProductWithInventory,
SkinConcern, SkinConcern,
) )
from innercontext.models.enums import (
AbsorptionSpeed,
DayTime,
PriceTier,
SkinType,
TextureType,
)
from innercontext.models.product import ( from innercontext.models.product import (
ActiveIngredient, ActiveIngredient,
ProductContext, ProductContext,
ProductEffectProfile, ProductEffectProfile,
ProductInteraction, ProductInteraction,
) )
from innercontext.models.enums import (
AbsorptionSpeed,
DayTime,
PriceTier,
TextureType,
SkinType,
)
router = APIRouter() router = APIRouter()
@ -35,57 +38,8 @@ router = APIRouter()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class ProductCreate(SQLModel): class ProductCreate(ProductBase):
name: str pass
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 ProductUpdate(SQLModel): class ProductUpdate(SQLModel):
@ -159,17 +113,12 @@ class InventoryUpdate(SQLModel):
notes: Optional[str] = None notes: Optional[str] = None
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Product routes # Product routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("", response_model=list[Product]) @router.get("", response_model=list[ProductPublic])
def list_products( def list_products(
category: Optional[ProductCategory] = None, category: Optional[ProductCategory] = None,
brand: Optional[str] = None, brand: Optional[str] = None,
@ -205,7 +154,7 @@ def list_products(
return 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)): def create_product(data: ProductCreate, session: Session = Depends(get_session)):
product = Product( product = Product(
id=uuid4(), id=uuid4(),
@ -217,16 +166,18 @@ def create_product(data: ProductCreate, session: Session = Depends(get_session))
return product return product
@router.get("/{product_id}") @router.get("/{product_id}", response_model=ProductWithInventory)
def get_product(product_id: UUID, session: Session = Depends(get_session)): def get_product(product_id: UUID, session: Session = Depends(get_session)):
product = get_or_404(session, Product, product_id) product = get_or_404(session, Product, product_id)
inventory = session.exec(select(ProductInventory).where(ProductInventory.product_id == product_id)).all() inventory = session.exec(
data = product.model_dump(mode="json") select(ProductInventory).where(ProductInventory.product_id == product_id)
data["inventory"] = [item.model_dump(mode="json") for item in inventory] ).all()
return data 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( def update_product(
product_id: UUID, data: ProductUpdate, session: Session = Depends(get_session) 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]) @router.get("/{product_id}/inventory", response_model=list[ProductInventory])
def list_product_inventory( def list_product_inventory(product_id: UUID, session: Session = Depends(get_session)):
product_id: UUID, session: Session = Depends(get_session)
):
get_or_404(session, Product, product_id) get_or_404(session, Product, product_id)
stmt = select(ProductInventory).where(ProductInventory.product_id == product_id) stmt = select(ProductInventory).where(ProductInventory.product_id == product_id)
return session.exec(stmt).all() return session.exec(stmt).all()

View file

@ -2,7 +2,7 @@ from datetime import date
from typing import Optional from typing import Optional
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from sqlmodel import Session, SQLModel, select from sqlmodel import Session, SQLModel, select
from db import get_session from db import get_session
@ -105,7 +105,9 @@ def list_grooming_schedule(session: Session = Depends(get_session)):
@router.get("/{routine_id}") @router.get("/{routine_id}")
def get_routine(routine_id: UUID, session: Session = Depends(get_session)): def get_routine(routine_id: UUID, session: Session = Depends(get_session)):
routine = get_or_404(session, Routine, routine_id) 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 = routine.model_dump(mode="json")
data["steps"] = [step.model_dump(mode="json") for step in steps] data["steps"] = [step.model_dump(mode="json") for step in steps]
return data return data
@ -206,9 +208,7 @@ def update_grooming_schedule(
@router.delete("/grooming-schedule/{entry_id}", status_code=204) @router.delete("/grooming-schedule/{entry_id}", status_code=204)
def delete_grooming_schedule( def delete_grooming_schedule(entry_id: UUID, session: Session = Depends(get_session)):
entry_id: UUID, session: Session = Depends(get_session)
):
entry = get_or_404(session, GroomingSchedule, entry_id) entry = get_or_404(session, GroomingSchedule, entry_id)
session.delete(entry) session.delete(entry)
session.commit() session.commit()

View file

@ -2,12 +2,16 @@ from datetime import date
from typing import Optional from typing import Optional
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from sqlmodel import Session, SQLModel, select from sqlmodel import Session, SQLModel, select
from db import get_session from db import get_session
from innercontext.api.utils import get_or_404 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 ( from innercontext.models.enums import (
BarrierState, BarrierState,
OverallSkinState, OverallSkinState,
@ -24,23 +28,8 @@ router = APIRouter()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class SnapshotCreate(SQLModel): class SnapshotCreate(SkinConditionSnapshotBase):
snapshot_date: date pass
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 SnapshotUpdate(SQLModel): class SnapshotUpdate(SQLModel):
@ -62,17 +51,12 @@ class SnapshotUpdate(SQLModel):
notes: Optional[str] = None notes: Optional[str] = None
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routes # Routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("", response_model=list[SkinConditionSnapshot]) @router.get("", response_model=list[SkinConditionSnapshotPublic])
def list_snapshots( def list_snapshots(
from_date: Optional[date] = None, from_date: Optional[date] = None,
to_date: Optional[date] = None, to_date: Optional[date] = None,
@ -89,10 +73,8 @@ def list_snapshots(
return session.exec(stmt).all() return session.exec(stmt).all()
@router.post("", response_model=SkinConditionSnapshot, status_code=201) @router.post("", response_model=SkinConditionSnapshotPublic, status_code=201)
def create_snapshot( def create_snapshot(data: SnapshotCreate, session: Session = Depends(get_session)):
data: SnapshotCreate, session: Session = Depends(get_session)
):
snapshot = SkinConditionSnapshot(id=uuid4(), **data.model_dump()) snapshot = SkinConditionSnapshot(id=uuid4(), **data.model_dump())
session.add(snapshot) session.add(snapshot)
session.commit() session.commit()
@ -100,12 +82,12 @@ def create_snapshot(
return 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)): def get_snapshot(snapshot_id: UUID, session: Session = Depends(get_session)):
return get_or_404(session, SkinConditionSnapshot, snapshot_id) 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( def update_snapshot(
snapshot_id: UUID, snapshot_id: UUID,
data: SnapshotUpdate, data: SnapshotUpdate,

View file

@ -25,13 +25,20 @@ from .health import LabResult, MedicationEntry, MedicationUsage
from .product import ( from .product import (
ActiveIngredient, ActiveIngredient,
Product, Product,
ProductBase,
ProductContext, ProductContext,
ProductEffectProfile, ProductEffectProfile,
ProductInteraction, ProductInteraction,
ProductInventory, ProductInventory,
ProductPublic,
ProductWithInventory,
) )
from .routine import GroomingSchedule, Routine, RoutineStep from .routine import GroomingSchedule, Routine, RoutineStep
from .skincare import SkinConditionSnapshot from .skincare import (
SkinConditionSnapshot,
SkinConditionSnapshotBase,
SkinConditionSnapshotPublic,
)
__all__ = [ __all__ = [
# domain # domain
@ -64,14 +71,19 @@ __all__ = [
# product # product
"ActiveIngredient", "ActiveIngredient",
"Product", "Product",
"ProductBase",
"ProductContext", "ProductContext",
"ProductEffectProfile", "ProductEffectProfile",
"ProductInteraction", "ProductInteraction",
"ProductInventory", "ProductInventory",
"ProductPublic",
"ProductWithInventory",
# routine # routine
"GroomingSchedule", "GroomingSchedule",
"Routine", "Routine",
"RoutineStep", "RoutineStep",
# skincare # skincare
"SkinConditionSnapshot", "SkinConditionSnapshot",
"SkinConditionSnapshotBase",
"SkinConditionSnapshotPublic",
] ]

View file

@ -1,6 +1,5 @@
from enum import Enum from enum import Enum
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Shared / Product # Shared / Product
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -21,7 +21,6 @@ from .enums import (
TextureType, TextureType,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Value objects # 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): class ProductBase(SQLModel):
__tablename__ = "products"
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
id: UUID = Field(default_factory=uuid4, primary_key=True)
name: str name: str
brand: str brand: str
line_name: str | None = Field(default=None, max_length=128) line_name: str | None = Field(default=None, max_length=128)
@ -107,10 +101,61 @@ class Product(SQLModel, table=True):
absorption_speed: AbsorptionSpeed | None = None absorption_speed: AbsorptionSpeed | None = None
leave_on: bool 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) size_ml: float | None = Field(default=None, gt=0)
pao_months: int | None = Field(default=None, ge=1, le=60) 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( inci: list[str] = Field(
default_factory=list, sa_column=Column(JSON, nullable=False) default_factory=list, sa_column=Column(JSON, nullable=False)
) )
@ -128,21 +173,12 @@ class Product(SQLModel, table=True):
contraindications: list[str] = Field( contraindications: list[str] = Field(
default_factory=list, sa_column=Column(JSON, nullable=False) 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( product_effect_profile: ProductEffectProfile = Field(
default_factory=ProductEffectProfile, default_factory=ProductEffectProfile,
sa_column=Column(JSON, nullable=False), 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( incompatible_with: list[ProductInteraction] | None = Field(
default=None, sa_column=Column(JSON, nullable=True) 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) 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) created_at: datetime = Field(default_factory=utc_now, nullable=False)
updated_at: datetime = Field( updated_at: datetime = Field(
default_factory=utc_now, default_factory=utc_now,
@ -264,7 +290,6 @@ class Product(SQLModel, table=True):
ctx["ph_max"] = self.ph_max ctx["ph_max"] = self.ph_max
ep = self.product_effect_profile ep = self.product_effect_profile
if ep is not None:
if isinstance(ep, dict): if isinstance(ep, dict):
nonzero = {k: v for k, v in ep.items() if v >= 2} nonzero = {k: v for k, v in ep.items() if v >= 2}
else: else:
@ -307,7 +332,12 @@ class Product(SQLModel, table=True):
ctx["max_frequency_per_week"] = self.max_frequency_per_week ctx["max_frequency_per_week"] = self.max_frequency_per_week
safety = {} 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) val = getattr(self, flag)
if val is not None: if val is not None:
safety[flag] = val safety[flag] = val
@ -358,3 +388,18 @@ class ProductInventory(SQLModel, table=True):
created_at: datetime = Field(default_factory=utc_now, nullable=False) created_at: datetime = Field(default_factory=utc_now, nullable=False)
product: Optional["Product"] = Relationship(back_populates="inventory") 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] = []

View file

@ -11,19 +11,13 @@ from .base import utc_now
from .domain import Domain from .domain import Domain
from .enums import BarrierState, OverallSkinState, SkinConcern, SkinTrend, SkinType 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 15.
"""
__tablename__ = "skin_condition_snapshots" class SkinConditionSnapshotBase(SQLModel):
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE}) snapshot_date: date
__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)
overall_state: OverallSkinState | None = None overall_state: OverallSkinState | None = None
trend: SkinTrend | None = None trend: SkinTrend | None = None
@ -38,17 +32,53 @@ class SkinConditionSnapshot(SQLModel, table=True):
barrier_state: BarrierState | None = None barrier_state: BarrierState | None = None
# Aktywne troski — podzbiór SkinConcern widoczny na zdjęciach lub wynikający z rutyny # 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 15.
"""
__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( active_concerns: list[SkinConcern] = Field(
default_factory=list, sa_column=Column(JSON, nullable=False) default_factory=list, sa_column=Column(JSON, nullable=False)
) )
# Wolny tekst — LLM wypełnia na podstawie analizy
risks: list[str] = Field( risks: list[str] = Field(
default_factory=list, sa_column=Column(JSON, nullable=False) default_factory=list, sa_column=Column(JSON, nullable=False)
) )
priorities: list[str] = Field( priorities: list[str] = Field(
default_factory=list, sa_column=Column(JSON, nullable=False) default_factory=list, sa_column=Column(JSON, nullable=False)
) )
notes: str | None = None
created_at: datetime = Field(default_factory=utc_now, nullable=False) created_at: datetime = Field(default_factory=utc_now, nullable=False)
# ---------------------------------------------------------------------------
# Public response model
# ---------------------------------------------------------------------------
class SkinConditionSnapshotPublic(SkinConditionSnapshotBase):
id: UUID
created_at: datetime

View file

@ -4,11 +4,11 @@ from dotenv import load_dotenv
load_dotenv() # load .env before db.py reads DATABASE_URL load_dotenv() # load .env before db.py reads DATABASE_URL
from fastapi import FastAPI from fastapi import FastAPI # noqa: E402
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware # noqa: E402
from db import create_db_and_tables from db import create_db_and_tables # noqa: E402
from innercontext.api import health, inventory, products, routines, skincare from innercontext.api import health, inventory, products, routines, skincare # noqa: E402
@asynccontextmanager @asynccontextmanager

View file

@ -1,6 +1,5 @@
import uuid import uuid
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Medications # Medications
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -32,9 +31,7 @@ def test_list_filter_kind(client):
client.post( client.post(
"/health/medications", json={"kind": "prescription", "product_name": "A"} "/health/medications", json={"kind": "prescription", "product_name": "A"}
) )
client.post( client.post("/health/medications", json={"kind": "supplement", "product_name": "B"})
"/health/medications", json={"kind": "supplement", "product_name": "B"}
)
r = client.get("/health/medications?kind=supplement") r = client.get("/health/medications?kind=supplement")
assert r.status_code == 200 assert r.status_code == 200
data = r.json() data = r.json()
@ -46,9 +43,7 @@ def test_list_filter_product_name(client):
client.post( client.post(
"/health/medications", json={"kind": "otc", "product_name": "Epiduo Forte"} "/health/medications", json={"kind": "otc", "product_name": "Epiduo Forte"}
) )
client.post( client.post("/health/medications", json={"kind": "otc", "product_name": "Panoxyl"})
"/health/medications", json={"kind": "otc", "product_name": "Panoxyl"}
)
r = client.get("/health/medications?product_name=epid") r = client.get("/health/medications?product_name=epid")
assert r.status_code == 200 assert r.status_code == 200
data = r.json() data = r.json()
@ -113,7 +108,11 @@ def test_create_usage(client, created_medication):
mid = created_medication["record_id"] mid = created_medication["record_id"]
r = client.post( r = client.post(
f"/health/medications/{mid}/usages", 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 assert r.status_code == 201
data = r.json() data = r.json()
@ -156,7 +155,9 @@ def test_update_usage(client, created_medication):
) )
uid = r.json()["record_id"] 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.status_code == 200
assert r2.json()["dose_value"] == 2.5 assert r2.json()["dose_value"] == 2.5
assert r2.json()["dose_unit"] == "mg" assert r2.json()["dose_unit"] == "mg"
@ -247,8 +248,14 @@ def test_list_filter_flag(client):
def test_list_filter_date_range(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(
client.post("/health/lab-results", json={**LAB_RESULT_DATA, "collected_at": "2026-06-01T00:00:00"}) "/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") r = client.get("/health/lab-results?from_date=2026-05-01T00:00:00")
assert r.status_code == 200 assert r.status_code == 200
data = r.json() data = r.json()
@ -271,7 +278,9 @@ def test_get_lab_result_not_found(client):
def test_update_lab_result(client): def test_update_lab_result(client):
r = client.post("/health/lab-results", json=LAB_RESULT_DATA) r = client.post("/health/lab-results", json=LAB_RESULT_DATA)
rid = r.json()["record_id"] 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.status_code == 200
assert r2.json()["notes"] == "Recheck in 3 months" assert r2.json()["notes"] == "Recheck in 3 months"

View file

@ -1,9 +1,7 @@
"""Unit tests for Product.to_llm_context() — no database required.""" """Unit tests for Product.to_llm_context() — no database required."""
from uuid import uuid4
from typing import Any from typing import Any
from uuid import uuid4
import pytest
from innercontext.models import Product from innercontext.models import Product
from innercontext.models.enums import ( from innercontext.models.enums import (

View file

@ -55,7 +55,9 @@ def test_list_filter_category(client, client_and_data=None):
"recommended_time": "both", "recommended_time": "both",
"leave_on": True, "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"}) r2 = client.post("/products", json={**base, "name": "Ser", "category": "serum"})
assert r1.status_code == 201 assert r1.status_code == 201
assert r2.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) # is_medication=True requires usage_notes (model validator)
client.post( client.post(
"/products", "/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") r = client.get("/products?is_medication=true")

View file

@ -1,6 +1,5 @@
import uuid import uuid
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routines # Routines
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -112,9 +112,7 @@ def test_update_snapshot_concerns(client):
def test_update_snapshot_not_found(client): def test_update_snapshot_not_found(client):
r = client.patch( r = client.patch(f"/skincare/{uuid.uuid4()}", json={"overall_state": "good"})
f"/skincare/{uuid.uuid4()}", json={"overall_state": "good"}
)
assert r.status_code == 404 assert r.status_code == 404