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:
Craig
2026-07-26 15:46:24 +01:00
parent f8048da9c1
commit 32461b7405
7 changed files with 1095 additions and 46 deletions
+83 -26
View File
@@ -6,10 +6,10 @@ Handlers stay thin (~15 lines) by calling into these functions.
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy import desc, func, select
from sqlalchemy.orm import Session
from models import Food
from models import DailyLogEntry, Food
from schemas import FoodCreate, FoodRead, FoodUpdate
@@ -66,38 +66,36 @@ def get_food(db: Session, food_id: int) -> FoodRead | None:
def create_food(db: Session, data: FoodCreate) -> FoodRead:
"""Create a food. Checks barcode uniqueness on live foods (409).
"""Create a food. Barcode uniqueness on LIVE foods 409.
Restore-on-rescan (§3.1): when the barcode matches a SOFT-DELETED food
(deleted_at IS NOT NULL), clear deleted_at and UPDATE that existing row
with the new data instead of inserting a duplicate. All in one transaction.
SQLite treats NULL barcodes as distinct, so multiple barcode-less foods
are fine."""
are fine.
"""
if data.barcode is not None:
existing = db.scalar(
select(Food).where(Food.barcode == data.barcode)
)
if existing is not None:
if existing.deleted_at is not None:
# ── Restore-on-rescan: update the soft-deleted row ──
_apply_create_data(existing, data)
existing.deleted_at = None
existing.updated_at = _now()
db.commit()
db.refresh(existing)
return FoodRead.model_validate(existing)
# Live food with same barcode → conflict
raise BarcodeConflictError(data.barcode)
now = datetime.now(timezone.utc).replace(tzinfo=None)
food = Food(
name=data.name,
brand=data.brand,
barcode=data.barcode,
source=data.source,
is_meal=data.is_meal,
unit_type=data.unit_type,
calories_per_unit=data.calories_per_unit,
protein_per_unit=data.protein_per_unit,
carbs_per_unit=data.carbs_per_unit,
fat_per_unit=data.fat_per_unit,
fiber_per_unit=data.fiber_per_unit,
saturated_fat_per_unit=data.saturated_fat_per_unit,
sugars_per_unit=data.sugars_per_unit,
sodium_per_unit=data.sodium_per_unit,
serving_size_g=data.serving_size_g,
serving_name=data.serving_name,
off_data=data.off_data,
created_at=now,
updated_at=now,
)
now = _now()
food = Food()
_apply_create_data(food, data)
food.created_at = now
food.updated_at = now
db.add(food)
db.commit()
db.refresh(food)
@@ -144,6 +142,65 @@ def delete_food(db: Session, food_id: int) -> FoodRead | None:
return FoodRead.model_validate(food)
# ── Recent foods (TICKET-006) ────────────────────────────────────────────────
def get_recent_foods(db: Session, limit: int = 10) -> list[FoodRead]:
"""Return foods ordered by most recent appearance in daily_log.
Deduplicated: each food appears at most once.
Excludes soft-deleted foods (§8.1 rule 7).
Ordered by the daily_log entry's created_at (the moment the food was
logged), NOT by the food's own created_at and NOT by the log ``date``
field. This means a food you just backfilled to an old date still
appears as "recent" because your log *action* was recent — which is
the intended UX.
If no foods have ever been logged, returns an empty list.
"""
last_logged = func.max(DailyLogEntry.created_at).label("last_logged")
stmt = (
select(Food, last_logged)
.join(DailyLogEntry, DailyLogEntry.food_id == Food.id)
.where(_not_deleted())
.group_by(Food.id)
.order_by(desc("last_logged"))
.limit(limit)
)
rows = db.execute(stmt).all()
return [FoodRead.model_validate(row[0]) for row in rows]
# ── Internal helpers ─────────────────────────────────────────────────────────
def _now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _apply_create_data(food: Food, data: FoodCreate) -> None:
"""Copy FoodCreate fields onto a Food ORM object (used for both insert
and restore-on-rescan update paths)."""
food.name = data.name
food.brand = data.brand
food.barcode = data.barcode
food.source = data.source
food.is_meal = data.is_meal
food.unit_type = data.unit_type
food.calories_per_unit = data.calories_per_unit
food.protein_per_unit = data.protein_per_unit
food.carbs_per_unit = data.carbs_per_unit
food.fat_per_unit = data.fat_per_unit
food.fiber_per_unit = data.fiber_per_unit
food.saturated_fat_per_unit = data.saturated_fat_per_unit
food.sugars_per_unit = data.sugars_per_unit
food.sodium_per_unit = data.sodium_per_unit
food.serving_size_g = data.serving_size_g
food.serving_name = data.serving_name
food.off_data = data.off_data
# ── Errors ───────────────────────────────────────────────────────────────────
+266
View File
@@ -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)