Files
lifetrack/apps/api/app/modules/health/foods.py
T
MeeJayandClaude Opus 5 fa3db7ff22 Fix 20+ defects found by real Docker/PostgreSQL deployment
Première exécution réelle de la stack (build des images, PostgreSQL 16,
parcours fonctionnels en HTTP) : 28 tables, 105 index, extension pg_trgm,
et 181 assertions rejouées après correction.

Santé : filtres enum invalides renvoyaient 500 au lieu d'une erreur
française ; plan_line s'arrêtait à la fin de la fenêtre du graphique au
lieu de la date d'atteinte de l'objectif ; deficit_target_kcal était
recalculé après le plancher calorique ; « dernière pesée » affichait deux
valeurs différentes selon l'endpoint ; objectif protéines absent.

Vape : durée de vie moyenne des résistances incluait la résistance en
cours ; archiver la recette active la laissait active ; coût théorique
inventé avant la date d'arrêt ; économies projetées dans le futur ;
€/ml arrondi à 2 décimales écrasait le modèle de coût DIY.

Finances : le sankey compensait crédits et débits non catégorisés ;
rows_total excluait les lignes filtrées, faussant l'arithmétique du
rapport d'import.

Socle : les erreurs HTTP du framework fuitaient en anglais dans
l'enveloppe française ; nginx renvoyait sa page 413 HTML au lieu du JSON
français ; fins de ligne normalisées en LF.

348 tests pytest (+7), ruff, tsc et vite build au vert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:40:05 +02:00

237 lines
8.4 KiB
Python

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