Files
Craig 32461b7405 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)
2026-07-26 15:46:24 +01:00

68 lines
2.3 KiB
Python

"""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).
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
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
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):
"""Look up a product by barcode on OpenFoodFacts and return normalized
data matching our FoodCreate shape.
Returns 404 when OFF has status=0, no product, or the product is
unusable (no name and no computable calories).
"""
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)