238 lines
9.3 KiB
Python
238 lines
9.3 KiB
Python
# -*- 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
|