"""Food referential: local `food_items` cache first, Open Food Facts proxy next. Rules from docs/research/nutrition-sources.md §4.3: - every OFF call goes through the backend (never the browser); - mandatory custom User-Agent `AppName/Version (ContactEmail)`; - each fetched product is cached in `food_items` so it is never fetched twice; - OFF unreachable must degrade gracefully (French 503 only when nothing local). Tests inject an `httpx.MockTransport` through `set_http_transport` — the suite never touches the network. """ from decimal import Decimal from typing import Any import httpx from sqlalchemy import func, or_, select from sqlalchemy.orm import Session from app.core.errors import AppError from app.modules.health.models import FoodItem from app.modules.health.schemas import FoodSearchItem, FoodSearchResponse OFF_SEARCH_URL = "https://search.openfoodfacts.org/search" OFF_PRODUCT_URL = "https://world.openfoodfacts.org/api/v2/product/{barcode}.json" OFF_USER_AGENT = "LifeTrack/1.0 (meejayproduction@gmail.com)" OFF_TIMEOUT_S = 5.0 OFF_SOURCE = "off" OFF_MAX_PAGE_SIZE = 100 OFF_FIELDS = ( "code,product_name,product_name_fr,brands,quantity,serving_size," "serving_quantity,nutriments" ) _TRANSPORT: httpx.BaseTransport | None = None class ServiceUnavailableError(AppError): status_code, code = 503, "service_unavailable" def set_http_transport(transport: httpx.BaseTransport | None) -> None: """Test seam: inject an httpx transport (MockTransport) for OFF calls.""" global _TRANSPORT _TRANSPORT = transport def _client() -> httpx.Client: return httpx.Client( timeout=OFF_TIMEOUT_S, headers={"User-Agent": OFF_USER_AGENT}, transport=_TRANSPORT, ) def _num(value: Any) -> Decimal | None: if value is None or value == "": return None try: return Decimal(str(float(value))) except (TypeError, ValueError, ArithmeticError): return None def _text(value: Any) -> str: """OFF fields are user-contributed: a field documented as a string sometimes arrives as a list (multi-value) or a number. Coerce anything to a clean string.""" if value is None: return "" if isinstance(value, (list, tuple)): value = value[0] if value else "" return str(value).strip() def _kcal_100g(nutriments: dict[str, Any]) -> Decimal | None: """kcal per 100 g, recomputed from kJ when the kcal field is missing.""" kcal = _num(nutriments.get("energy-kcal_100g")) if kcal is not None: return kcal kj = _num(nutriments.get("energy_100g")) or _num(nutriments.get("energy-kj_100g")) if kj is None: return None return Decimal(str(round(float(kj) / 4.184, 1))) def normalize_off_product(product: dict[str, Any]) -> dict[str, Any] | None: """Map one OFF product payload to `food_items` columns (per 100 g).""" name = _text(product.get("product_name_fr")) or _text(product.get("product_name")) if not name: return None nutriments = product.get("nutriments") if not isinstance(nutriments, dict): nutriments = {} return { "source": OFF_SOURCE, "source_id": _text(product.get("code")) or None, "name": name[:200], "brand": _text(product.get("brands")).split(",")[0].strip()[:100] or None, "energy_kcal_100g": _kcal_100g(nutriments), "protein_g_100g": _num(nutriments.get("proteins_100g")), "carbs_g_100g": _num(nutriments.get("carbohydrates_100g")), "sugar_g_100g": _num(nutriments.get("sugars_100g")), "fat_g_100g": _num(nutriments.get("fat_100g")), "sat_fat_g_100g": _num(nutriments.get("saturated-fat_100g")), "fiber_g_100g": _num(nutriments.get("fiber_100g")), "salt_g_100g": _num(nutriments.get("salt_100g")), "serving_size_g": _num(product.get("serving_quantity")), "raw": product, } def cache_product(db: Session, data: dict[str, Any]) -> FoodItem: """Upsert one product into the local cache, keyed by (source, source_id).""" item: FoodItem | None = None if data["source_id"]: item = db.scalar( select(FoodItem).where( FoodItem.source == data["source"], FoodItem.source_id == data["source_id"], ) ) if item is None: item = FoodItem(source=data["source"], source_id=data["source_id"]) db.add(item) for key, value in data.items(): if key not in {"source", "source_id"}: setattr(item, key, value) return item def search_local(db: Session, query: str, limit: int) -> list[FoodItem]: pattern = f"%{query.lower()}%" stmt = ( select(FoodItem) .where( or_( func.lower(FoodItem.name).like(pattern), func.lower(func.coalesce(FoodItem.brand, "")).like(pattern), ) ) .order_by(FoodItem.name.asc()) .limit(limit) ) return list(db.scalars(stmt).all()) def fetch_off_search(query: str, limit: int) -> list[dict[str, Any]]: """Full-text search on Open Food Facts (Search-a-licious).""" with _client() as client: response = client.get( OFF_SEARCH_URL, params={"q": query, "langs": "fr", "page_size": limit}, ) response.raise_for_status() payload = response.json() hits = payload.get("hits") if hits is None: hits = payload.get("products") or [] return [hit for hit in hits if isinstance(hit, dict)] def fetch_off_product(barcode: str) -> dict[str, Any] | None: with _client() as client: response = client.get( OFF_PRODUCT_URL.format(barcode=barcode), params={"fields": OFF_FIELDS} ) response.raise_for_status() payload = response.json() if payload.get("status") in (0, "failure"): return None return payload.get("product") def search_foods(db: Session, query: str, limit: int = 20) -> FoodSearchResponse: """Local cache first, then Open Food Facts; caches every OFF hit.""" query = query.strip() if len(query) < 2: raise AppError("Saisissez au moins 2 caractères pour rechercher un aliment.") local = search_local(db, query, limit) items = [FoodSearchItem.model_validate(row) for row in local] if len(items) >= limit: return FoodSearchResponse(query=query, items=items, origin="cache") known = {(row.source, row.source_id) for row in local} # The OFF results overlap the local cache: products already cached are skipped # below, so asking only for the missing count under-delivers as soon as the # cache is partially warm. Ask for `limit` new products plus the overlap. page_size = min(limit + len(known), OFF_MAX_PAGE_SIZE) try: hits = fetch_off_search(query, page_size) except (httpx.HTTPError, ValueError) as exc: if items: return FoodSearchResponse(query=query, items=items, origin="cache") raise ServiceUnavailableError( "La base Open Food Facts est momentanément injoignable. " "Réessayez plus tard ou saisissez l'aliment manuellement." ) from exc for hit in hits: data = normalize_off_product(hit) if data is None or (data["source"], data["source_id"]) in known: continue known.add((data["source"], data["source_id"])) cached = cache_product(db, data) items.append(FoodSearchItem.model_validate(cached)) db.commit() return FoodSearchResponse(query=query, items=items[:limit], origin="cache+off") def food_by_barcode(db: Session, barcode: str) -> FoodSearchItem: """Cache lookup, then OFF product endpoint.""" barcode = barcode.strip() cached = db.scalar( select(FoodItem).where( FoodItem.source == OFF_SOURCE, FoodItem.source_id == barcode ) ) if cached is not None: return FoodSearchItem.model_validate(cached) try: product = fetch_off_product(barcode) except (httpx.HTTPError, ValueError) as exc: raise ServiceUnavailableError( "La base Open Food Facts est momentanément injoignable. " "Réessayez plus tard ou saisissez l'aliment manuellement." ) from exc data = normalize_off_product(product or {}) if data is None: from app.core.errors import NotFoundError raise NotFoundError("Aucun produit ne correspond à ce code-barres.") item = cache_product(db, data) db.commit() db.refresh(item) return FoodSearchItem.model_validate(item)