# -*- coding: utf-8 -*- """ Gemeinsamer Tandoor-Zugang für die Plugins der Suite. Die drei mitgebrachten Original-Tools bringen ihren eigenen Client mit und bleiben unangetastet. Alles, was danach in dieser Suite entsteht, benutzt diesen hier — damit Paginierung, Wiederholversuche und Fehlermeldungen nur an einer Stelle gepflegt werden müssen. Belegt am Tandoor-Quelltext (cookbook/serializer.py, cookbook/urls.py): Food id, name, plural_name, description, url, fdc_id, properties, properties_food_amount, properties_food_unit, supermarket_category, numrecipe (nur lesbar), full_name Property id, property_amount, property_type PropertyType id, name, unit, description, order, open_data_slug, fdc_id Endpunkte /api/food/ /api/unit/ /api/keyword/ /api/property-type/ /api/property/ /api/recipe/ Zusammenführen PUT /api/food/{id}/merge/{target}/ """ from __future__ import annotations import os import time from typing import Any, Iterator import requests class TandoorError(RuntimeError): """Fehler beim Reden mit Tandoor — mit lesbarer Meldung.""" class TandoorClient: def __init__( self, base_url: str, token: str, auth_scheme: str = "Bearer", timeout: float = 45.0, verify: bool = True, retries: int = 2, ) -> None: if not base_url or not token: raise TandoorError("Tandoor-URL oder Token fehlt.") self.base_url = base_url.rstrip("/") self.timeout = timeout self.retries = max(0, retries) self.session = requests.Session() self.session.verify = verify self.session.headers.update({ "Authorization": f"{auth_scheme} {token}".strip(), "Accept": "application/json", "User-Agent": "boehmitools/1.0", }) self._cache: dict[str, list[dict[str, Any]]] = {} # ------------------------------------------------------------------ HTTP def request(self, method: str, path: str, **kwargs: Any) -> requests.Response: url = f"{self.base_url}/{path.lstrip('/')}" last: Exception | None = None for attempt in range(self.retries + 1): try: response = self.session.request( method, url, timeout=self.timeout, **kwargs ) except requests.RequestException as exc: last = exc if attempt < self.retries: time.sleep(0.6 * (attempt + 1)) continue raise TandoorError(f"{method} {url} nicht erreichbar: {exc}") from exc # 5xx sind oft vorübergehend, 4xx nicht. if response.status_code >= 500 and attempt < self.retries: time.sleep(0.6 * (attempt + 1)) continue if response.status_code >= 400: raise TandoorError( f"{method} {url} → HTTP {response.status_code}: " f"{response.text[:400]}" ) return response raise TandoorError(f"{method} {url} fehlgeschlagen: {last}") def get_json(self, path: str, **kwargs: Any) -> Any: return self.request("GET", path, **kwargs).json() def patch_json(self, path: str, payload: dict[str, Any]) -> Any: return self.request("PATCH", path, json=payload).json() def put_json(self, path: str, payload: dict[str, Any] | None = None) -> Any: response = self.request("PUT", path, json=payload or {}) if not response.content: return None try: return response.json() except ValueError: return None def post_json(self, path: str, payload: dict[str, Any]) -> Any: return self.request("POST", path, json=payload).json() def delete(self, path: str) -> None: self.request("DELETE", path) # ----------------------------------------------------------- Sammlungen def iter_objects(self, endpoint: str, params: dict[str, Any] | None = None) -> Iterator[dict[str, Any]]: """Läuft eine paginierte Liste vollständig ab.""" query = {"page_size": 200} query.update(params or {}) path: str | None = f"api/{endpoint.strip('/')}/" first = True while path: payload = self.get_json(path, params=query if first else None) first = False if isinstance(payload, list): yield from (item for item in payload if isinstance(item, dict)) return if not isinstance(payload, dict): raise TandoorError(f"Unerwartete Antwort von {endpoint}.") yield from ( item for item in payload.get("results") or [] if isinstance(item, dict) ) nxt = payload.get("next") if not nxt: return # "next" ist eine absolute URL; für die nächste Runde relativ machen. path = nxt[len(self.base_url):] if nxt.startswith(self.base_url) else nxt def list_objects(self, endpoint: str, refresh: bool = False) -> list[dict[str, Any]]: """Wie iter_objects, aber gepuffert — ein Lauf fragt jede Liste einmal ab.""" if refresh or endpoint not in self._cache: self._cache[endpoint] = list(self.iter_objects(endpoint)) return self._cache[endpoint] def forget(self, endpoint: str | None = None) -> None: if endpoint is None: self._cache.clear() else: self._cache.pop(endpoint, None) # -------------------------------------------------------------- Bequem def ping(self) -> dict[str, Any]: """Kurzer Verbindungstest; wirft TandoorError, wenn etwas klemmt.""" payload = self.get_json("api/food/", params={"page_size": 1}) count = payload.get("count") if isinstance(payload, dict) else None return {"ok": True, "foods": count, "base_url": self.base_url} def merge(self, endpoint: str, source_id: int, target_id: int) -> None: """ PUT /api/{endpoint}/{source}/merge/{target}/ Achtung: Tandoor hängt alle Verweise der Quelle auf das Ziel um und löscht die Quelle. Die *Properties der Quelle* gehen dabei verloren (so gewollt im Tandoor-Code, um Dubletten zu vermeiden) — es muss also immer der schlechtere Eintrag auf den besseren zeigen. """ if source_id == target_id: raise TandoorError("Quelle und Ziel sind identisch.") self.put_json(f"api/{endpoint}/{source_id}/merge/{target_id}/") @classmethod def from_env(cls, **overrides: Any) -> "TandoorClient": def flag(name: str, default: str) -> bool: return os.environ.get(name, default).strip().casefold() not in { "0", "false", "no", "nein", "off", } settings: dict[str, Any] = { "base_url": os.environ.get("TANDOOR_URL", "").strip(), "token": os.environ.get("TANDOOR_TOKEN", "").strip(), "auth_scheme": os.environ.get("TANDOOR_AUTH_SCHEME", "Bearer").strip() or "Bearer", "timeout": float(os.environ.get("TANDOOR_TIMEOUT", "45") or 45), "verify": flag("TANDOOR_VERIFY_TLS", "true"), } settings.update({k: v for k, v in overrides.items() if v is not None}) return cls(**settings) # --------------------------------------------------------------- Properties def food_property_map(food: dict[str, Any]) -> dict[int, float | None]: """{property_type_id: Wert} für ein Food-Objekt.""" result: dict[int, float | None] = {} for prop in food.get("properties") or []: if not isinstance(prop, dict): continue ptype = prop.get("property_type") or {} type_id = ptype.get("id") if isinstance(ptype, dict) else None if isinstance(type_id, int): amount = prop.get("property_amount") result[type_id] = None if amount is None else float(amount) return result def merged_properties_payload( food: dict[str, Any], updates: dict[int, float], ) -> list[dict[str, Any]]: """ Baut die vollständige properties-Liste für einen PATCH. Tandoor ersetzt die Liste komplett (drf-writable-nested). Wer nur den neuen Wert schickt, verliert die vorhandenen. Deshalb wird hier immer der Bestand übernommen und nur ergänzt oder überschrieben. """ payload: list[dict[str, Any]] = [] seen: set[int] = set() for prop in food.get("properties") or []: if not isinstance(prop, dict): continue ptype = prop.get("property_type") or {} type_id = ptype.get("id") if not isinstance(type_id, int): continue seen.add(type_id) amount = updates.get(type_id, prop.get("property_amount")) entry: dict[str, Any] = { "property_amount": None if amount is None else float(amount), "property_type": {"id": type_id}, } if isinstance(prop.get("id"), int): entry["id"] = prop["id"] payload.append(entry) for type_id, amount in updates.items(): if type_id not in seen: payload.append({ "property_amount": float(amount), "property_type": {"id": type_id}, }) return payload