Files
lifetrack/apps/api/app/modules/health/foods.py
T
MeeJayandClaude Opus 5 93f0689c1e Initial import: LifeTrack v1 (santé, vape, finances)
Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de
pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et
finances personnelles avec import de relevés bancaires.

Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS +
Vite + Tailwind + ECharts, déploiement Docker Compose. Modules
auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework
de connecteurs à deux voies (importeurs de fichiers + ingestion JSON)
pour brancher de nouvelles sources sans toucher au noyau.

Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié
contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16
(28 tables, SPA servie par nginx, wizard de premier démarrage).

Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les
documents de conception et de recherche dans docs/.

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

232 lines
8.1 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_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}
try:
hits = fetch_off_search(query, limit - len(items))
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)