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:
commit
8f7d893a63
32 changed files with 6282 additions and 0 deletions
295
backend/innercontext/api/products.py
Normal file
295
backend/innercontext/api/products.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue