TICKET-006 (backend): OFF normalization, scan/search/refresh, restore-on-rescan
- services/off.py: single normalizer module (kcal/kJ fallback, not-found
rule); fetch_product/search_off degrade gracefully on upstream errors
(503/timeout → None / []), never 500; User-Agent + timeouts on all calls
- routers/off.py: thin routes for GET /api/off/product/{barcode},
GET /api/off/search, POST /api/off/refresh/{food_id} (404 unknown id,
400 no-barcode)
- services/foods.py: restore-on-rescan (§3.1) — barcode collision on a
soft-deleted food clears deleted_at + updates row instead of 409;
GET /api/foods/recent ordered by most-recent daily_log appearance,
deduped, soft-deleted excluded
- tests: httpx mocked at the boundary (MockTransport, no real OFF);
48 new tests (normalizer unit + router + restore + recent + error paths)
- 152 passing (was 104)
This commit is contained in:
@@ -10,6 +10,7 @@ from services.foods import (
|
||||
create_food,
|
||||
delete_food,
|
||||
get_food,
|
||||
get_recent_foods,
|
||||
list_foods,
|
||||
update_food,
|
||||
)
|
||||
@@ -17,6 +18,17 @@ from services.foods import (
|
||||
router = APIRouter(prefix="/api/foods", tags=["foods"])
|
||||
|
||||
|
||||
@router.get("/recent", response_model=list[FoodRead])
|
||||
def _recent_foods(
|
||||
limit: int = Query(default=10, le=50),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Foods ordered by most recent appearance in daily_log, deduplicated.
|
||||
Soft-deleted foods excluded. Ordered by most-recently-LOGGED, not by
|
||||
foods.created_at."""
|
||||
return get_recent_foods(db, limit=limit)
|
||||
|
||||
|
||||
@router.get("", response_model=list[FoodRead])
|
||||
def _list_foods(
|
||||
q: str | None = None,
|
||||
|
||||
+51
-14
@@ -3,28 +3,65 @@
|
||||
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).
|
||||
|
||||
Routes:
|
||||
GET /api/off/product/{barcode} — lookup + normalize; 404 if not found
|
||||
GET /api/off/search?q= — search + normalize each hit; [] if none
|
||||
POST /api/off/refresh/{food_id} — re-fetch by stored barcode, update row
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
OFF_BASE_URL = "https://world.openfoodfacts.org"
|
||||
OFF_USER_AGENT = "CalCount/0.1 (personal self-hosted calorie counter)"
|
||||
OFF_TIMEOUT = 10.0
|
||||
from database import get_db
|
||||
from services.off import (
|
||||
NoBarcodeError,
|
||||
RefreshError,
|
||||
fetch_product,
|
||||
refresh_food,
|
||||
search_off,
|
||||
)
|
||||
|
||||
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.
|
||||
"""Look up a product by barcode on OpenFoodFacts and return normalized
|
||||
data matching our FoodCreate shape.
|
||||
|
||||
TODO: normalize to our foods schema (spec §3.5) in one shared module.
|
||||
Returns 404 when OFF has status=0, no product, or the product is
|
||||
unusable (no name and no computable calories).
|
||||
"""
|
||||
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()
|
||||
result = fetch_product(barcode)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Product not found for barcode: {barcode}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
def get_search(q: str = Query(..., min_length=1, description="Search query")):
|
||||
"""Search OpenFoodFacts by text query. Returns a list of normalized
|
||||
food dicts (matching our FoodCreate shape). Empty list when nothing
|
||||
is found or all results are unusable.
|
||||
"""
|
||||
return search_off(q)
|
||||
|
||||
|
||||
@router.post("/refresh/{food_id}")
|
||||
def post_refresh(food_id: int, db: Session = Depends(get_db)):
|
||||
"""Re-fetch a food's data from OpenFoodFacts by its stored barcode,
|
||||
normalize, and update the local row (nutrition, name, brand, serving,
|
||||
off_data; bumps updated_at).
|
||||
|
||||
404 — food_id not found, or OFF no longer has the product.
|
||||
400 — the food has no barcode (can't refresh).
|
||||
"""
|
||||
try:
|
||||
return refresh_food(db, food_id)
|
||||
except RefreshError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.detail)
|
||||
except NoBarcodeError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.detail)
|
||||
|
||||
Reference in New Issue
Block a user