Initial project scaffold: FastAPI backend + Svelte 5 frontend
Backend (uv): FastAPI app with routers (foods, log, targets, OFF proxy), SQLAlchemy models, Pydantic schemas, migration runner + 0001 initial schema, example pytest suite (7 tests). Frontend (npm): Vite 7 + Svelte 5 runes, lib/ (api, stores, scanner, format) per spec §7, placeholder components, example vitest suite (4 tests). SPEC.md §1: registered uvicorn and Vitest (rule §8.3.3).
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
"""Foods router (spec §3.1). Thin: validate → service → schema (spec §8.1 rule 2)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import Food
|
||||
from schemas import FoodRead
|
||||
|
||||
router = APIRouter(prefix="/api/foods", tags=["foods"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[FoodRead])
|
||||
def list_foods(
|
||||
q: str | None = None,
|
||||
limit: int = Query(default=50, le=200),
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Search local foods. Soft-deleted foods are hidden (spec §8.1 rule 7)."""
|
||||
stmt = select(Food).where(Food.deleted_at.is_(None)).limit(limit).offset(offset)
|
||||
if q:
|
||||
stmt = stmt.where(Food.name.contains(q) | Food.brand.contains(q))
|
||||
return db.scalars(stmt).all()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Daily log router (spec §3.3)."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import DailyLogEntry
|
||||
from schemas import LogEntryRead
|
||||
|
||||
router = APIRouter(prefix="/api/log", tags=["log"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[LogEntryRead])
|
||||
def get_log(date: date, db: Session = Depends(get_db)):
|
||||
"""All entries for a client-supplied date (spec §8.1 rule 8)."""
|
||||
stmt = (
|
||||
select(DailyLogEntry)
|
||||
.where(DailyLogEntry.date == date)
|
||||
.order_by(DailyLogEntry.sort_order, DailyLogEntry.id)
|
||||
)
|
||||
return db.scalars(stmt).all()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""OpenFoodFacts proxy (spec §3.5). All OFF calls go through this router.
|
||||
|
||||
OFF etiquette (spec §8.1 rule 10): descriptive User-Agent, timeouts, and
|
||||
kcal-vs-kJ normalization in exactly one module. Tests mock at the httpx
|
||||
boundary — never hit the real OFF API (spec §8.4).
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
|
||||
OFF_BASE_URL = "https://world.openfoodfacts.org"
|
||||
OFF_USER_AGENT = "CalCount/0.1 (personal self-hosted calorie counter)"
|
||||
OFF_TIMEOUT = 10.0
|
||||
|
||||
router = APIRouter(prefix="/api/off", tags=["off"])
|
||||
|
||||
|
||||
@router.get("/product/{barcode}")
|
||||
def get_product(barcode: str):
|
||||
"""Proxy a product lookup by barcode. Returns raw OFF JSON for now.
|
||||
|
||||
TODO: normalize to our foods schema (spec §3.5) in one shared module.
|
||||
"""
|
||||
resp = httpx.get(
|
||||
f"{OFF_BASE_URL}/api/v2/product/{barcode}",
|
||||
headers={"User-Agent": OFF_USER_AGENT},
|
||||
timeout=OFF_TIMEOUT,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Targets router (spec §3.4)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import Target
|
||||
from schemas import TargetRead
|
||||
|
||||
router = APIRouter(prefix="/api/targets", tags=["targets"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TargetRead])
|
||||
def list_targets(db: Session = Depends(get_db)):
|
||||
return db.scalars(select(Target).order_by(Target.start_date)).all()
|
||||
|
||||
|
||||
@router.get("/current", response_model=TargetRead)
|
||||
def current_target(db: Session = Depends(get_db)):
|
||||
target = db.scalar(select(Target).where(Target.end_date.is_(None)))
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="No active target")
|
||||
return target
|
||||
Reference in New Issue
Block a user