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:
+83
-26
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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