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,295 @@
from datetime import date
from typing import Optional
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, SQLModel, select
from db import get_session
from innercontext.models import (
Product,
ProductCategory,
ProductInventory,
SkinConcern,
)
from innercontext.models.product import (
ActiveIngredient,
ProductContext,
ProductEffectProfile,
ProductInteraction,
)
from innercontext.models.enums import (
AbsorptionSpeed,
DayTime,
EvidenceLevel,
PriceTier,
RoutineRole,
TextureType,
UsageFrequency,
SkinType,
)
router = APIRouter()
# ---------------------------------------------------------------------------
# Request / response schemas
# ---------------------------------------------------------------------------
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
routine_role: RoutineRole
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] = []
recommended_frequency: Optional[UsageFrequency] = None
targets: list[SkinConcern] = []
contraindications: list[str] = []
usage_notes: Optional[str] = None
evidence_level: Optional[EvidenceLevel] = None
claims: list[str] = []
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_rating: Optional[int] = None
personal_tolerance_notes: Optional[str] = None
personal_repurchase_intent: Optional[bool] = None
class ProductUpdate(SQLModel):
name: Optional[str] = None
brand: Optional[str] = None
line_name: Optional[str] = None
sku: Optional[str] = None
url: Optional[str] = None
barcode: Optional[str] = None
category: Optional[ProductCategory] = None
routine_role: Optional[RoutineRole] = None
recommended_time: Optional[DayTime] = None
texture: Optional[TextureType] = None
absorption_speed: Optional[AbsorptionSpeed] = None
leave_on: Optional[bool] = None
price_tier: Optional[PriceTier] = None
size_ml: Optional[float] = None
pao_months: Optional[int] = None
inci: Optional[list[str]] = None
actives: Optional[list[ActiveIngredient]] = None
recommended_for: Optional[list[SkinType]] = None
recommended_frequency: Optional[UsageFrequency] = None
targets: Optional[list[SkinConcern]] = None
contraindications: Optional[list[str]] = None
usage_notes: Optional[str] = None
evidence_level: Optional[EvidenceLevel] = None
claims: Optional[list[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: Optional[ProductEffectProfile] = None
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: Optional[bool] = None
is_tool: Optional[bool] = None
needle_length_mm: Optional[float] = None
personal_rating: Optional[int] = None
personal_tolerance_notes: Optional[str] = None
personal_repurchase_intent: Optional[bool] = None
class InventoryCreate(SQLModel):
is_opened: bool = False
opened_at: Optional[date] = None
finished_at: Optional[date] = None
expiry_date: Optional[date] = None
current_weight_g: Optional[float] = None
notes: Optional[str] = None
class InventoryUpdate(SQLModel):
is_opened: Optional[bool] = None
opened_at: Optional[date] = None
finished_at: Optional[date] = None
expiry_date: Optional[date] = None
current_weight_g: Optional[float] = None
notes: Optional[str] = None
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def get_or_404(session: Session, model, record_id) -> object:
obj = session.get(model, record_id)
if obj is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
return obj
# ---------------------------------------------------------------------------
# Product routes
# ---------------------------------------------------------------------------
@router.get("/", response_model=list[Product])
def list_products(
category: Optional[ProductCategory] = None,
brand: Optional[str] = None,
targets: Optional[list[SkinConcern]] = Query(default=None),
is_medication: Optional[bool] = None,
is_tool: Optional[bool] = None,
session: Session = Depends(get_session),
):
stmt = select(Product)
if category is not None:
stmt = stmt.where(Product.category == category)
if brand is not None:
stmt = stmt.where(Product.brand == brand)
if is_medication is not None:
stmt = stmt.where(Product.is_medication == is_medication)
if is_tool is not None:
stmt = stmt.where(Product.is_tool == is_tool)
products = session.exec(stmt).all()
# Filter by targets (JSON column — done in Python)
if targets:
target_values = {t.value for t in targets}
products = [
p
for p in products
if any(
(t.value if hasattr(t, "value") else t) in target_values
for t in (p.targets or [])
)
]
return products
@router.post("/", response_model=Product, status_code=201)
def create_product(data: ProductCreate, session: Session = Depends(get_session)):
product = Product(
id=uuid4(),
**data.model_dump(),
)
session.add(product)
session.commit()
session.refresh(product)
return product
@router.get("/{product_id}", response_model=Product)
def get_product(product_id: UUID, session: Session = Depends(get_session)):
return get_or_404(session, Product, product_id)
@router.patch("/{product_id}", response_model=Product)
def update_product(
product_id: UUID, data: ProductUpdate, session: Session = Depends(get_session)
):
product = get_or_404(session, Product, product_id)
for key, value in data.model_dump(exclude_unset=True).items():
setattr(product, key, value)
session.add(product)
session.commit()
session.refresh(product)
return product
@router.delete("/{product_id}", status_code=204)
def delete_product(product_id: UUID, session: Session = Depends(get_session)):
product = get_or_404(session, Product, product_id)
session.delete(product)
session.commit()
# ---------------------------------------------------------------------------
# Product inventory sub-routes
# ---------------------------------------------------------------------------
@router.get("/{product_id}/inventory", response_model=list[ProductInventory])
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()
@router.post(
"/{product_id}/inventory", response_model=ProductInventory, status_code=201
)
def create_product_inventory(
product_id: UUID,
data: InventoryCreate,
session: Session = Depends(get_session),
):
get_or_404(session, Product, product_id)
entry = ProductInventory(
id=uuid4(),
product_id=product_id,
**data.model_dump(),
)
session.add(entry)
session.commit()
session.refresh(entry)
return entry