32461b7405
- 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)
267 lines
9.3 KiB
Python
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)
|