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:
@@ -0,0 +1,266 @@
|
||||
"""OFF normalization + HTTP calls (spec §8.1 rule 10).
|
||||
|
||||
All OFF→foods field mapping lives here and nowhere else.
|
||||
Tests mock at the httpx boundary — never hit the real OFF API (spec §8.4).
|
||||
|
||||
Normalization field mapping (OFF v2 → FoodCreate):
|
||||
- name: product_name (fallback: generic_name)
|
||||
- brand: brands (comma-separated → first entry trimmed; see _first_brand)
|
||||
- barcode: code
|
||||
- source: "openfoodfacts"
|
||||
- unit_type: "weight" (OFF nutrition is per-100g)
|
||||
- calories_per_unit: nutriments["energy-kcal_100g"], or
|
||||
nutriments["energy-kj_100g"] / 4.184 if kcal absent → rounded 1dp
|
||||
- *_per_unit: nutriments["{field}_100g"] (absent → null)
|
||||
- serving_size_g: serving_quantity parsed as float (grams) if present
|
||||
- serving_name: serving_size (human string) if present
|
||||
- off_data: raw product JSON serialized to string
|
||||
|
||||
Not-found rule: returns None when the product has no usable name AND no
|
||||
computable calories (both missing → not a useful food).
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import Food
|
||||
from schemas import FoodRead
|
||||
|
||||
OFF_BASE_URL = "https://world.openfoodfacts.org"
|
||||
OFF_USER_AGENT = "CalCount/0.1 (personal self-hosted calorie counter)"
|
||||
OFF_TIMEOUT = 10.0
|
||||
|
||||
# 1 kcal = 4.184 kJ → kJ_to_kcal = 1 / 4.184
|
||||
_KJ_TO_KCAL = 1.0 / 4.184
|
||||
|
||||
|
||||
# ── Client factory (mocked at the httpx boundary in tests) ───────────────────
|
||||
|
||||
|
||||
def _default_client() -> httpx.Client:
|
||||
"""Return an httpx Client with our User-Agent and timeout."""
|
||||
return httpx.Client(
|
||||
headers={"User-Agent": OFF_USER_AGENT},
|
||||
timeout=OFF_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
# ── Normalization (the ONE module — spec §8.1 rule 10) ──────────────────────
|
||||
|
||||
|
||||
def _first_brand(brands_raw: str | None) -> str | None:
|
||||
"""OFF brands is comma-separated (e.g. "Nutella, Ferrero, Yum yum").
|
||||
We split on comma, trim whitespace, and return the first non-empty entry.
|
||||
Returns None when the string is empty or all-whitespace."""
|
||||
if not brands_raw or not brands_raw.strip():
|
||||
return None
|
||||
parts = [p.strip() for p in brands_raw.split(",")]
|
||||
for p in parts:
|
||||
if p:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _parse_float(value: object) -> float | None:
|
||||
"""Coerce an OFF value (number or string) to float. Returns None on failure."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def normalize_off_product(product: dict) -> dict | None:
|
||||
"""Map an OFF v2 product JSON object → dict matching FoodCreate.
|
||||
|
||||
Returns None when the product is unusable: no name AND no computable
|
||||
calories. This is the "not-found" signal for the proxy layer.
|
||||
"""
|
||||
nutriments = product.get("nutriments") or {}
|
||||
|
||||
# ── name ──
|
||||
name = (product.get("product_name") or product.get("generic_name") or "").strip()
|
||||
|
||||
# ── calories: prefer kcal; fall back to kJ → kcal conversion ──
|
||||
kcal = _parse_float(nutriments.get("energy-kcal_100g"))
|
||||
if kcal is None:
|
||||
kj = _parse_float(nutriments.get("energy-kj_100g"))
|
||||
if kj is not None:
|
||||
kcal = round(kj * _KJ_TO_KCAL, 1)
|
||||
|
||||
# ── not-found check ──
|
||||
if not name and kcal is None:
|
||||
return None
|
||||
|
||||
# ── brand ──
|
||||
brand = _first_brand(product.get("brands"))
|
||||
|
||||
# ── serving ──
|
||||
serving_qty = _parse_float(product.get("serving_quantity"))
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"brand": brand,
|
||||
"barcode": str(product.get("code", "")),
|
||||
"source": "openfoodfacts",
|
||||
"is_meal": False,
|
||||
"unit_type": "weight",
|
||||
"calories_per_unit": kcal,
|
||||
"protein_per_unit": _parse_float(nutriments.get("proteins_100g")),
|
||||
"carbs_per_unit": _parse_float(nutriments.get("carbohydrates_100g")),
|
||||
"fat_per_unit": _parse_float(nutriments.get("fat_100g")),
|
||||
"fiber_per_unit": _parse_float(nutriments.get("fiber_100g")),
|
||||
"saturated_fat_per_unit": _parse_float(nutriments.get("saturated-fat_100g")),
|
||||
"sugars_per_unit": _parse_float(nutriments.get("sugars_100g")),
|
||||
"sodium_per_unit": _parse_float(nutriments.get("sodium_100g")),
|
||||
"serving_size_g": serving_qty,
|
||||
"serving_name": product.get("serving_size") or None,
|
||||
"off_data": json.dumps(product),
|
||||
}
|
||||
|
||||
|
||||
# ── OFF proxy calls ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def fetch_product(barcode: str, client: httpx.Client | None = None) -> dict | None:
|
||||
"""Fetch a product from OFF by barcode and return normalized data.
|
||||
|
||||
Returns the normalized food dict (FoodCreate shape), or None if OFF
|
||||
has no product / status ≠ 1 / unusable data.
|
||||
|
||||
Upstream errors (HTTP 5xx, timeouts, connection failures) are treated
|
||||
as "not found" (returns None → router maps to 404) so the API degrades
|
||||
gracefully instead of crashing with a 500.
|
||||
|
||||
Pass *client* with a MockTransport in tests; otherwise a default
|
||||
httpx.Client is created (and closed) per call.
|
||||
"""
|
||||
own = client is None
|
||||
if own:
|
||||
client = _default_client()
|
||||
try:
|
||||
resp = client.get(f"{OFF_BASE_URL}/api/v2/product/{barcode}")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("status") != 1 or not data.get("product"):
|
||||
return None
|
||||
return normalize_off_product(data["product"])
|
||||
except (httpx.HTTPStatusError, httpx.RequestError):
|
||||
# Upstream unavailable or transport failure — degrade gracefully.
|
||||
return None
|
||||
finally:
|
||||
if own:
|
||||
client.close()
|
||||
|
||||
|
||||
def search_off(query: str, client: httpx.Client | None = None) -> list[dict]:
|
||||
"""Search OFF by text query and return a list of normalized food dicts.
|
||||
|
||||
Fields requested: code, product_name, generic_name, brands, nutriments,
|
||||
serving_quantity, serving_size. Page size is capped at 20.
|
||||
|
||||
Returns an empty list when OFF has no matches, all results are
|
||||
unusable after normalization, or an upstream/transport error occurs
|
||||
(graceful degradation — no 500s).
|
||||
"""
|
||||
own = client is None
|
||||
if own:
|
||||
client = _default_client()
|
||||
try:
|
||||
resp = client.get(
|
||||
f"{OFF_BASE_URL}/api/v2/search",
|
||||
params={
|
||||
"search_terms": query,
|
||||
"fields": "code,product_name,generic_name,brands,"
|
||||
"nutriments,serving_quantity,serving_size",
|
||||
"page_size": 20,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
results: list[dict] = []
|
||||
for p in data.get("products", []):
|
||||
norm = normalize_off_product(p)
|
||||
if norm is not None:
|
||||
results.append(norm)
|
||||
return results
|
||||
except (httpx.HTTPStatusError, httpx.RequestError):
|
||||
# Upstream unavailable or transport failure — degrade gracefully.
|
||||
return []
|
||||
finally:
|
||||
if own:
|
||||
client.close()
|
||||
|
||||
|
||||
# ── Refresh (§3.5) ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class RefreshError(Exception):
|
||||
"""Errors from the refresh endpoint that map to HTTP status codes."""
|
||||
|
||||
def __init__(self, status_code: int, detail: str):
|
||||
super().__init__(detail)
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
class NoBarcodeError(RefreshError):
|
||||
"""Food has no barcode — can't refresh from OFF."""
|
||||
|
||||
def __init__(self, food_id: int):
|
||||
super().__init__(
|
||||
400,
|
||||
f"Food {food_id} has no barcode — cannot refresh from OpenFoodFacts",
|
||||
)
|
||||
|
||||
|
||||
def refresh_food(
|
||||
db: Session, food_id: int, client: httpx.Client | None = None
|
||||
) -> FoodRead:
|
||||
"""Re-fetch a food's data from OFF by its stored barcode and update the
|
||||
local row (nutrition, name, brand, serving, off_data; bump updated_at).
|
||||
|
||||
Raises:
|
||||
RefreshError(404) — food_id not found
|
||||
NoBarcodeError(400) — food has no barcode
|
||||
RefreshError(404) — OFF no longer has the product
|
||||
"""
|
||||
food = db.get(Food, food_id)
|
||||
if food is None:
|
||||
raise RefreshError(404, f"Food {food_id} not found")
|
||||
|
||||
barcode = food.barcode
|
||||
if not barcode:
|
||||
raise NoBarcodeError(food_id)
|
||||
|
||||
norm = fetch_product(barcode, client=client)
|
||||
if norm is None:
|
||||
raise RefreshError(404, f"Barcode '{barcode}' no longer found on OpenFoodFacts")
|
||||
|
||||
# Update the local row with fresh OFF data
|
||||
food.name = norm["name"]
|
||||
food.brand = norm["brand"]
|
||||
food.calories_per_unit = norm["calories_per_unit"]
|
||||
food.protein_per_unit = norm["protein_per_unit"]
|
||||
food.carbs_per_unit = norm["carbs_per_unit"]
|
||||
food.fat_per_unit = norm["fat_per_unit"]
|
||||
food.fiber_per_unit = norm["fiber_per_unit"]
|
||||
food.saturated_fat_per_unit = norm["saturated_fat_per_unit"]
|
||||
food.sugars_per_unit = norm["sugars_per_unit"]
|
||||
food.sodium_per_unit = norm["sodium_per_unit"]
|
||||
food.serving_size_g = norm["serving_size_g"]
|
||||
food.serving_name = norm["serving_name"]
|
||||
food.off_data = norm["off_data"]
|
||||
food.updated_at = _now()
|
||||
|
||||
db.commit()
|
||||
db.refresh(food)
|
||||
return FoodRead.model_validate(food)
|
||||
Reference in New Issue
Block a user