Files
calcount/backend/services/off.py
T
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

267 lines
9.3 KiB
Python

"""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)