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>
232 lines
8.1 KiB
Python
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)
|