Initial commit: backend API, data models, and test suite

FastAPI backend for personal health and skincare data with MCP export.
Includes SQLModel models for products, inventory, medications, lab results,
routines, and skin condition snapshots. Pytest suite with 111 tests running
on SQLite in-memory (no PostgreSQL required).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Piotr Oleszczyk 2026-02-26 15:10:24 +01:00
commit 8f7d893a63
32 changed files with 6282 additions and 0 deletions

View file

@ -0,0 +1,377 @@
from datetime import date, datetime
from typing import ClassVar, Optional
from uuid import UUID, uuid4
from pydantic import model_validator
from sqlalchemy import JSON, Column, DateTime
from sqlmodel import Field, Relationship, SQLModel
from .base import utc_now
from .domain import Domain
from .enums import (
AbsorptionSpeed,
DayTime,
EvidenceLevel,
IngredientFunction,
InteractionScope,
PriceTier,
ProductCategory,
RoutineRole,
SkinConcern,
SkinType,
StrengthLevel,
TextureType,
UsageFrequency,
)
# ---------------------------------------------------------------------------
# Value objects
# ---------------------------------------------------------------------------
class ProductEffectProfile(SQLModel):
hydration_immediate: int = Field(default=0, ge=0, le=5)
hydration_long_term: int = Field(default=0, ge=0, le=5)
barrier_repair_strength: int = Field(default=0, ge=0, le=5)
soothing_strength: int = Field(default=0, ge=0, le=5)
exfoliation_strength: int = Field(default=0, ge=0, le=5)
retinoid_strength: int = Field(default=0, ge=0, le=5)
irritation_risk: int = Field(default=0, ge=0, le=5)
comedogenic_risk: int = Field(default=0, ge=0, le=5)
barrier_disruption_risk: int = Field(default=0, ge=0, le=5)
dryness_risk: int = Field(default=0, ge=0, le=5)
brightening_strength: int = Field(default=0, ge=0, le=5)
anti_acne_strength: int = Field(default=0, ge=0, le=5)
anti_aging_strength: int = Field(default=0, ge=0, le=5)
class ActiveIngredient(SQLModel):
name: str
percent: float | None = Field(default=None, ge=0, le=100)
functions: list[IngredientFunction] = Field(default_factory=list)
strength_level: StrengthLevel | None = None
irritation_potential: StrengthLevel | None = None
cumulative_with: list[IngredientFunction] | None = None
class ProductInteraction(SQLModel):
target: str
scope: InteractionScope
reason: str | None = None
class ProductContext(SQLModel):
safe_after_shaving: bool | None = None
safe_after_acids: bool | None = None
safe_after_retinoids: bool | None = None
safe_with_compromised_barrier: bool | None = None
low_uv_only: bool | None = None
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _ev(v: object) -> str:
"""Return enum value or string as-is (handles both DB-loaded dicts and Python enums)."""
return v.value if hasattr(v, "value") else str(v) # type: ignore[union-attr]
# ---------------------------------------------------------------------------
# Table models
# ---------------------------------------------------------------------------
class Product(SQLModel, table=True):
__tablename__ = "products"
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
id: UUID = Field(default_factory=uuid4, primary_key=True)
name: str
brand: str
line_name: str | None = Field(default=None, max_length=128)
sku: str | None = Field(default=None, max_length=64)
url: str | None = Field(default=None, max_length=512)
barcode: str | None = Field(default=None, max_length=64)
category: ProductCategory
routine_role: RoutineRole
recommended_time: DayTime
texture: TextureType | None = None
absorption_speed: AbsorptionSpeed | None = None
leave_on: bool
price_tier: PriceTier | None = Field(default=None, index=True)
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, sa_column=Column(JSON, nullable=False)
)
actives: list[ActiveIngredient] | None = Field(
default=None, sa_column=Column(JSON, nullable=True)
)
recommended_for: list[SkinType] = Field(
default_factory=list, sa_column=Column(JSON, nullable=False)
)
recommended_frequency: UsageFrequency | None = None
targets: list[SkinConcern] = Field(
default_factory=list, sa_column=Column(JSON, nullable=False)
)
contraindications: list[str] = Field(
default_factory=list, sa_column=Column(JSON, nullable=False)
)
usage_notes: str | None = None
evidence_level: EvidenceLevel | None = Field(default=None, index=True)
claims: list[str] = Field(
default_factory=list, sa_column=Column(JSON, nullable=False)
)
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)
)
synergizes_with: list[str] | None = Field(
default=None, sa_column=Column(JSON, nullable=True)
)
context_rules: ProductContext | None = Field(
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_rating: int | None = Field(default=None, ge=1, le=10)
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,
sa_column=Column(
DateTime(timezone=True),
default=utc_now,
onupdate=utc_now,
nullable=False,
),
)
inventory: list["ProductInventory"] = Relationship(back_populates="product")
@model_validator(mode="after")
def validate_business_rules(self) -> "Product":
if (
self.ph_min is not None
and self.ph_max is not None
and self.ph_min > self.ph_max
):
raise ValueError("ph_min must be <= ph_max")
if self.category == ProductCategory.SPF and self.recommended_time == DayTime.PM:
raise ValueError("SPF cannot be recommended only for PM")
if self.category == ProductCategory.SPF and not self.leave_on:
raise ValueError("SPF products must be leave-on")
if self.is_medication and not self.usage_notes:
raise ValueError("Medication products must have usage_notes")
return self
def to_llm_context(self) -> dict:
ctx: dict = {
"id": str(self.id),
"name": self.name,
"brand": self.brand,
"category": _ev(self.category),
"routine_role": _ev(self.routine_role),
"recommended_time": _ev(self.recommended_time),
"leave_on": self.leave_on,
}
for field in ("line_name", "sku", "url", "barcode"):
val = getattr(self, field)
if val is not None:
ctx[field] = val
if self.texture is not None:
ctx["texture"] = _ev(self.texture)
if self.absorption_speed is not None:
ctx["absorption_speed"] = _ev(self.absorption_speed)
if self.price_tier is not None:
ctx["price_tier"] = _ev(self.price_tier)
if self.size_ml is not None:
ctx["size_ml"] = self.size_ml
if self.pao_months is not None:
ctx["pao_months"] = self.pao_months
if self.recommended_frequency is not None:
ctx["recommended_frequency"] = _ev(self.recommended_frequency)
if self.evidence_level is not None:
ctx["evidence_level"] = _ev(self.evidence_level)
if self.inci:
ctx["inci"] = self.inci
if self.recommended_for:
ctx["recommended_for"] = [_ev(s) for s in self.recommended_for]
if self.targets:
ctx["targets"] = [_ev(s) for s in self.targets]
if self.contraindications:
ctx["contraindications"] = self.contraindications
if self.claims:
ctx["claims"] = self.claims
if self.actives:
actives_ctx = []
for a in self.actives:
if isinstance(a, dict):
actives_ctx.append(a)
else:
a_dict: dict = {"name": a.name}
if a.percent is not None:
a_dict["percent"] = a.percent
if a.functions:
a_dict["functions"] = [_ev(f) for f in a.functions]
if a.strength_level is not None:
a_dict["strength_level"] = a.strength_level.name.lower()
if a.cumulative_with:
a_dict["cumulative_with"] = [_ev(f) for f in a.cumulative_with]
actives_ctx.append(a_dict)
ctx["actives"] = actives_ctx
if self.ph_min is not None or self.ph_max is not None:
if self.ph_min == self.ph_max and self.ph_min is not None:
ctx["ph"] = self.ph_min
elif self.ph_min is not None and self.ph_max is not None:
ctx["ph_range"] = f"{self.ph_min}{self.ph_max}"
elif self.ph_min is not None:
ctx["ph_min"] = self.ph_min
else:
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}
else:
nonzero = {k: v for k, v in ep.model_dump().items() if v}
if nonzero:
ctx["effect_profile"] = nonzero
if self.incompatible_with:
parts = []
for inc in self.incompatible_with:
if isinstance(inc, dict):
scope = inc.get("scope", "")
target = inc.get("target", "")
reason = inc.get("reason")
else:
scope = _ev(inc.scope)
target = inc.target
reason = inc.reason
msg = f"avoid {target} ({scope})"
if reason:
msg += f": {reason}"
parts.append(msg)
ctx["incompatible_with"] = parts
if self.synergizes_with:
ctx["synergizes_with"] = self.synergizes_with
if self.context_rules is not None:
cr = self.context_rules
if isinstance(cr, dict):
rules = {k: v for k, v in cr.items() if v is not None}
else:
rules = {k: v for k, v in cr.model_dump().items() if v is not None}
if rules:
ctx["context_rules"] = rules
if self.min_interval_hours is not None:
ctx["min_interval_hours"] = self.min_interval_hours
if self.max_frequency_per_week is not None:
ctx["max_frequency_per_week"] = self.max_frequency_per_week
safety = {}
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
if safety:
ctx["safety"] = safety
if self.is_medication:
ctx["is_medication"] = True
if self.is_tool:
ctx["is_tool"] = True
if self.needle_length_mm is not None:
ctx["needle_length_mm"] = self.needle_length_mm
if self.usage_notes:
ctx["usage_notes"] = self.usage_notes
if self.personal_rating is not None:
ctx["personal_rating"] = self.personal_rating
if self.personal_tolerance_notes:
ctx["personal_tolerance_notes"] = self.personal_tolerance_notes
if self.personal_repurchase_intent is not None:
ctx["personal_repurchase_intent"] = self.personal_repurchase_intent
try:
opened_items = [
inv for inv in (self.inventory or []) if inv.is_opened and inv.opened_at
]
if opened_items:
most_recent = max(opened_items, key=lambda x: x.opened_at)
ctx["days_since_opened"] = (date.today() - most_recent.opened_at).days
except Exception:
pass
return ctx
class ProductInventory(SQLModel, table=True):
__tablename__ = "product_inventory"
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
id: UUID = Field(default_factory=uuid4, primary_key=True)
product_id: UUID = Field(foreign_key="products.id", index=True)
is_opened: bool = Field(default=False)
opened_at: date | None = Field(default=None)
finished_at: date | None = Field(default=None)
expiry_date: date | None = Field(default=None)
current_weight_g: float | None = Field(default=None, gt=0)
notes: str | None = None
created_at: datetime = Field(default_factory=utc_now, nullable=False)
product: Optional["Product"] = Relationship(back_populates="inventory")