Files
Vorrania/backend/app/off.py
Scarriffle 5b952524d7 Gegenstands-Verwaltung (Non-Food) neben Lebensmitteln
Die Kategorie bestimmt die Verwaltungsart (food/object). Gegenstaende:
Menge je Lagerort statt Chargen/MHD, Umlagern (ohne Grund) und Entfernen
mit Pflicht-Grund samt Entnahme-Statistik. Beliebig viele eigene Felder
je Kategorie (vererbt an Unterkategorien), "gekauft bei" ueber eine
verwaltbare Shop-Liste und ein Produktlink. Barcode-Lookup zusaetzlich
ueber Open Products Facts.

Der Lebensmittel-Teil (Chargen/MHD/FEFO) bleibt unveraendert. Umgesetzt in
Backend (FastAPI, +Tests gruen), Web (React) und iOS (SwiftUI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:33:56 +02:00

123 lines
4.0 KiB
Python

"""Open Food Facts Lookup-Client."""
from __future__ import annotations
import json
import re
import httpx
from .config import get_settings
settings = get_settings()
# Faktoren, um eine Einheit in die Basiseinheit (Gramm bzw. Milliliter) umzurechnen.
_UNIT_TO_BASE: dict[str, tuple[str, float]] = {
"kg": ("gram", 1000.0),
"g": ("gram", 1.0),
"mg": ("gram", 0.001),
"l": ("milliliter", 1000.0),
"dl": ("milliliter", 100.0),
"cl": ("milliliter", 10.0),
"ml": ("milliliter", 1.0),
}
_QUANTITY_RE = re.compile(r"([\d]+(?:[.,]\d+)?)\s*(kg|mg|g|dl|cl|ml|l)\b", re.IGNORECASE)
def parse_quantity(quantity: str | None) -> tuple[str, float | None]:
"""Ermittelt Basiseinheit und Packungsgröße aus dem OFF-Feld 'quantity'.
Beispiele: '500 g' -> ('gram', 500), '1 kg' -> ('gram', 1000),
'1,5 l' -> ('milliliter', 1500), '6 Stück' -> ('piece', None).
"""
if not quantity:
return "piece", None
match = _QUANTITY_RE.search(quantity)
if not match:
return "piece", None
amount = float(match.group(1).replace(",", "."))
base_unit, factor = _UNIT_TO_BASE[match.group(2).lower()]
package_size = round(amount * factor, 3)
return base_unit, (package_size if package_size > 0 else None)
def _lookup_at(barcode: str, base_url: str, source: str) -> dict | None:
"""Fragt eine Open-Facts-Instanz (OFF oder Open Products Facts) nach einem Barcode.
Beide Dienste teilen sich dieselbe API-Struktur; es unterscheidet sich nur die
Basis-URL. Gibt ein vorbefülltes Produkt-Dict zurück oder None.
"""
# v0-API: liefert bei Treffer status=1 + product, bei Nicht-Treffer status=0
# (HTTP 200). Die v2-API hat kein status-Feld und antwortet mit HTTP 404,
# weshalb wir bewusst v0 nutzen.
url = f"{base_url}/api/v0/product/{barcode}.json"
try:
resp = httpx.get(
url,
timeout=settings.off_timeout_seconds,
headers={"User-Agent": "Vorrania-Selfhosted/1.0"},
follow_redirects=True,
)
except httpx.HTTPError:
return None
if resp.status_code != 200:
return None
try:
data = resp.json()
except ValueError:
return None
if data.get("status") != 1:
return None
product = data.get("product", {})
name = (
product.get("product_name_de")
or product.get("product_name")
or product.get("generic_name")
or ""
).strip()
if not name:
return None
categories = product.get("categories") or ""
category_tags = product.get("categories_tags") or []
base_unit, package_size = parse_quantity(product.get("quantity"))
return {
"barcode": barcode,
"name": name,
"brand": (product.get("brands") or "").strip() or None,
"image_url": product.get("image_front_url") or product.get("image_url") or None,
"base_unit": base_unit,
"package_size": package_size,
"quantity_text": product.get("quantity"),
"category_suggestion": categories.split(",")[0].strip() if categories else None,
"category_tags": category_tags,
"source": source,
"off_raw": json.dumps(product)[:20000],
}
def lookup_barcode(barcode: str, prefer: str | None = None) -> dict | None:
"""Sucht einen Barcode in den offenen Datenbanken.
Beim Scannen steht die Kategorie noch nicht fest, deshalb werden beide Quellen
der Reihe nach befragt: Lebensmittel (Open Food Facts) und allgemeine Produkte
(Open Products Facts). ``prefer="object"`` stellt die allgemeine Produkt-DB nach
vorn (z.B. beim erneuten Abgleich eines Gegenstands), sonst gewinnt Essen.
"""
off = (settings.off_base_url, "off")
opf = (settings.opf_base_url, "opf")
reihenfolge = [opf, off] if prefer == "object" else [off, opf]
for base_url, source in reihenfolge:
if not base_url:
continue
treffer = _lookup_at(barcode, base_url, source)
if treffer is not None:
return treffer
return None