chore: initial import
This commit is contained in:
@@ -0,0 +1,798 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import unicodedata
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
|
||||
from .models import RecipeSpec
|
||||
|
||||
|
||||
UNIT_NORMALIZATION = {
|
||||
"teelöffel": "TL",
|
||||
"teeloeffel": "TL",
|
||||
"tsp": "TL",
|
||||
"teaspoon": "TL",
|
||||
"teaspoons": "TL",
|
||||
"esslöffel": "EL",
|
||||
"essloeffel": "EL",
|
||||
"tbsp": "EL",
|
||||
"tablespoon": "EL",
|
||||
"tablespoons": "EL",
|
||||
"gramm": "g",
|
||||
"grams": "g",
|
||||
"gram": "g",
|
||||
"kilogramm": "kg",
|
||||
"kilograms": "kg",
|
||||
"milliliter": "ml",
|
||||
"milliliters": "ml",
|
||||
"liter": "l",
|
||||
"litre": "l",
|
||||
"stück": "Stück",
|
||||
"stueck": "Stück",
|
||||
"piece": "Stück",
|
||||
"pieces": "Stück",
|
||||
"pinch": "Prise",
|
||||
"can": "Dose",
|
||||
}
|
||||
|
||||
|
||||
def folded(value: Any) -> str:
|
||||
return re.sub(r"\s+", " ", str(value or "").strip()).casefold()
|
||||
|
||||
|
||||
def comparable(value: Any) -> str:
|
||||
text = folded(value)
|
||||
text = text.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue")
|
||||
text = text.replace("ß", "ss")
|
||||
text = "".join(
|
||||
char
|
||||
for char in unicodedata.normalize("NFKD", text)
|
||||
if not unicodedata.combining(char)
|
||||
)
|
||||
return re.sub(r"[^a-z0-9]+", " ", text).strip()
|
||||
|
||||
|
||||
def results_from(payload: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(payload, list):
|
||||
return [item for item in payload if isinstance(item, dict)]
|
||||
if isinstance(payload, dict) and isinstance(payload.get("results"), list):
|
||||
return [item for item in payload["results"] if isinstance(item, dict)]
|
||||
return []
|
||||
|
||||
|
||||
class TandoorClient:
|
||||
def __init__(self) -> None:
|
||||
base_url = os.environ.get("TANDOOR_URL", "").strip()
|
||||
token = os.environ.get("TANDOOR_TOKEN", "").strip()
|
||||
scheme = os.environ.get("TANDOOR_AUTH_SCHEME", "Bearer").strip()
|
||||
if not base_url or not token:
|
||||
raise RuntimeError("TANDOOR_URL oder TANDOOR_TOKEN ist nicht gesetzt.")
|
||||
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = float(os.environ.get("TANDOOR_TIMEOUT", "45"))
|
||||
self.verify = os.environ.get("TANDOOR_VERIFY_TLS", "true").casefold() not in {
|
||||
"0",
|
||||
"false",
|
||||
"no",
|
||||
}
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Authorization": f"{scheme} {token}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "tandoor-ai-web-import/1.1",
|
||||
}
|
||||
)
|
||||
self._object_cache: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
def request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
||||
url = path if path.startswith(("http://", "https://")) else f"{self.base_url}/{path.lstrip('/')}"
|
||||
response = self.session.request(
|
||||
method,
|
||||
url,
|
||||
timeout=self.timeout,
|
||||
verify=self.verify,
|
||||
**kwargs,
|
||||
)
|
||||
if not response.ok:
|
||||
raise RuntimeError(
|
||||
f"Tandoor {method} {path}: HTTP {response.status_code}\n"
|
||||
f"{response.text[:4000]}"
|
||||
)
|
||||
return response
|
||||
|
||||
def get_json(self, path: str) -> Any:
|
||||
response = self.request("GET", path)
|
||||
try:
|
||||
return response.json()
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
def post_json(self, path: str, payload: Any) -> Any:
|
||||
response = self.request("POST", path, json=payload)
|
||||
try:
|
||||
return response.json()
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
def put_image_url(self, recipe_id: int, image_url: str) -> Any:
|
||||
response = self.request(
|
||||
"PUT",
|
||||
f"api/recipe/{recipe_id}/image/",
|
||||
files={"image_url": (None, image_url)},
|
||||
)
|
||||
try:
|
||||
return response.json()
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
def delete(self, path: str) -> None:
|
||||
response = self.request("DELETE", path)
|
||||
response.close()
|
||||
|
||||
def list_objects(
|
||||
self,
|
||||
endpoint: str,
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
if endpoint in self._object_cache and not force_refresh:
|
||||
return self._object_cache[endpoint]
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
next_url: str | None = f"api/{endpoint}/?page_size=500"
|
||||
seen_urls: set[str] = set()
|
||||
pages = 0
|
||||
while next_url and next_url not in seen_urls and pages < 30:
|
||||
seen_urls.add(next_url)
|
||||
payload = self.get_json(next_url)
|
||||
items.extend(results_from(payload))
|
||||
next_url = payload.get("next") if isinstance(payload, dict) else None
|
||||
pages += 1
|
||||
|
||||
deduplicated: dict[int, dict[str, Any]] = {}
|
||||
for item in items:
|
||||
object_id = item.get("id")
|
||||
if isinstance(object_id, int):
|
||||
deduplicated[object_id] = item
|
||||
result = list(deduplicated.values())
|
||||
self._object_cache[endpoint] = result
|
||||
return result
|
||||
|
||||
def forget_cache(self, endpoint: str) -> None:
|
||||
self._object_cache.pop(endpoint, None)
|
||||
|
||||
|
||||
def _last_word_forms(word: str) -> set[str]:
|
||||
word = word.strip()
|
||||
if not word:
|
||||
return set()
|
||||
|
||||
forms = {word}
|
||||
if len(word) <= 2:
|
||||
return forms
|
||||
|
||||
# Häufige deutsche Singular-/Pluralformen. Diese Heuristik dient nur der
|
||||
# Kandidatensuche. Die endgültige Auswahl bleibt im Dropdown sichtbar.
|
||||
if word.endswith("eln"):
|
||||
forms.add(word[:-1]) # Kartoffeln -> Kartoffel
|
||||
if word.endswith("ern"):
|
||||
forms.add(word[:-1])
|
||||
if word.endswith("en") and len(word) > 4:
|
||||
forms.add(word[:-2])
|
||||
forms.add(word[:-1])
|
||||
if word.endswith("n") and len(word) > 4:
|
||||
forms.add(word[:-1])
|
||||
if word.endswith("e"):
|
||||
forms.add(word + "n")
|
||||
else:
|
||||
forms.update({word + "e", word + "en", word + "n", word + "s"})
|
||||
|
||||
return {form for form in forms if len(form) >= 3}
|
||||
|
||||
|
||||
def search_variants(name: str) -> set[str]:
|
||||
normalized = comparable(name)
|
||||
if not normalized:
|
||||
return set()
|
||||
words = normalized.split()
|
||||
variants = {normalized}
|
||||
for last in _last_word_forms(words[-1]):
|
||||
variants.add(" ".join([*words[:-1], last]))
|
||||
variants.add(last)
|
||||
for word in words:
|
||||
variants.update(_last_word_forms(word))
|
||||
return {item for item in variants if item}
|
||||
|
||||
|
||||
def _object_names(obj: dict[str, Any]) -> list[str]:
|
||||
return [
|
||||
value
|
||||
for value in (
|
||||
obj.get("name"),
|
||||
obj.get("plural_name"),
|
||||
obj.get("full_name"),
|
||||
)
|
||||
if value
|
||||
]
|
||||
|
||||
|
||||
def _score_candidate(
|
||||
requested_name: str,
|
||||
obj: dict[str, Any],
|
||||
*,
|
||||
object_type: str,
|
||||
) -> tuple[float, str]:
|
||||
requested = comparable(requested_name)
|
||||
variants = search_variants(requested_name)
|
||||
best_score = 0.0
|
||||
best_reason = "ähnlich"
|
||||
|
||||
for raw_candidate in _object_names(obj):
|
||||
candidate = comparable(raw_candidate)
|
||||
if not candidate:
|
||||
continue
|
||||
if candidate == requested:
|
||||
score, reason = 100.0, "exakt"
|
||||
elif candidate in variants:
|
||||
score, reason = 96.0, "Singular/Plural"
|
||||
elif requested in search_variants(raw_candidate):
|
||||
score, reason = 95.0, "Singular/Plural"
|
||||
elif min(len(candidate), len(requested)) >= 4 and (
|
||||
candidate in requested or requested in candidate
|
||||
):
|
||||
short = min(len(candidate), len(requested))
|
||||
long = max(len(candidate), len(requested))
|
||||
score, reason = 84.0 + (short / long) * 7.0, "Teilwort"
|
||||
else:
|
||||
ratio = SequenceMatcher(None, requested, candidate).ratio()
|
||||
requested_words = set(requested.split())
|
||||
candidate_words = set(candidate.split())
|
||||
overlap = (
|
||||
len(requested_words & candidate_words)
|
||||
/ max(1, len(requested_words | candidate_words))
|
||||
)
|
||||
score = max(ratio * 78.0, overlap * 82.0)
|
||||
reason = "ähnlich"
|
||||
|
||||
if score > best_score:
|
||||
best_score, best_reason = score, reason
|
||||
|
||||
if object_type == "Food" and obj.get("properties"):
|
||||
best_score += 0.4
|
||||
return min(best_score, 100.0), best_reason
|
||||
|
||||
|
||||
def _candidate_summary(
|
||||
objects: list[dict[str, Any]],
|
||||
requested_name: str,
|
||||
*,
|
||||
object_type: str,
|
||||
limit: int = 25,
|
||||
) -> list[dict[str, Any]]:
|
||||
scored = []
|
||||
for obj in objects:
|
||||
score, reason = _score_candidate(
|
||||
requested_name,
|
||||
obj,
|
||||
object_type=object_type,
|
||||
)
|
||||
if score < 45.0:
|
||||
continue
|
||||
scored.append((score, obj, reason))
|
||||
|
||||
scored.sort(
|
||||
key=lambda item: (
|
||||
-item[0],
|
||||
-int(bool(item[1].get("properties"))),
|
||||
comparable(item[1].get("name")),
|
||||
)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": obj.get("id"),
|
||||
"name": obj.get("name"),
|
||||
"plural_name": obj.get("plural_name"),
|
||||
"full_name": obj.get("full_name"),
|
||||
"has_properties": bool(obj.get("properties")),
|
||||
"score": round(score, 1),
|
||||
"reason": reason,
|
||||
}
|
||||
for score, obj, reason in scored[:limit]
|
||||
if isinstance(obj.get("id"), int)
|
||||
]
|
||||
|
||||
|
||||
def _find_by_id(objects: list[dict[str, Any]], object_id: int) -> dict[str, Any] | None:
|
||||
return next((obj for obj in objects if obj.get("id") == object_id), None)
|
||||
|
||||
|
||||
def _find_exact(objects: list[dict[str, Any]], name: str) -> dict[str, Any] | None:
|
||||
target = comparable(name)
|
||||
for obj in objects:
|
||||
if target in {comparable(value) for value in _object_names(obj)}:
|
||||
return obj
|
||||
return None
|
||||
|
||||
|
||||
def resolve_object(
|
||||
client: TandoorClient,
|
||||
endpoint: str,
|
||||
requested_name: str,
|
||||
preferred_id: int | None,
|
||||
*,
|
||||
object_type: Literal["Food", "Unit"],
|
||||
create_requested: bool = False,
|
||||
plural_name: str | None = None,
|
||||
normalize_unit: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
lookup_name = requested_name.strip()
|
||||
normalized_from = None
|
||||
if normalize_unit:
|
||||
canonical = UNIT_NORMALIZATION.get(folded(lookup_name))
|
||||
if canonical and canonical != lookup_name:
|
||||
normalized_from = lookup_name
|
||||
lookup_name = canonical
|
||||
|
||||
objects = client.list_objects(endpoint)
|
||||
candidates = _candidate_summary(
|
||||
objects,
|
||||
lookup_name,
|
||||
object_type=object_type,
|
||||
)
|
||||
|
||||
if create_requested:
|
||||
return {
|
||||
"status": "create",
|
||||
"requested": requested_name,
|
||||
"lookup_name": lookup_name,
|
||||
"normalized_from": normalized_from,
|
||||
"resolved": {
|
||||
"name": lookup_name,
|
||||
"plural_name": plural_name,
|
||||
"create": True,
|
||||
"endpoint": endpoint,
|
||||
},
|
||||
"selected_id": None,
|
||||
"candidates": candidates,
|
||||
"blocking": False,
|
||||
"needs_review": True,
|
||||
"message": f"{object_type} wird beim Import neu angelegt.",
|
||||
}
|
||||
|
||||
if preferred_id is not None:
|
||||
obj = _find_by_id(objects, preferred_id)
|
||||
if obj is None:
|
||||
try:
|
||||
obj = client.get_json(f"api/{endpoint}/{preferred_id}/")
|
||||
except Exception as exc:
|
||||
return {
|
||||
"status": "missing_preferred",
|
||||
"requested": requested_name,
|
||||
"lookup_name": lookup_name,
|
||||
"normalized_from": normalized_from,
|
||||
"resolved": None,
|
||||
"selected_id": None,
|
||||
"candidates": candidates,
|
||||
"blocking": True,
|
||||
"needs_review": True,
|
||||
"message": f"Die ausgewählte ID {preferred_id} ist nicht erreichbar: {exc}",
|
||||
}
|
||||
return {
|
||||
"status": "preferred",
|
||||
"requested": requested_name,
|
||||
"lookup_name": lookup_name,
|
||||
"normalized_from": normalized_from,
|
||||
"resolved": {"id": obj["id"], "name": obj["name"]},
|
||||
"selected_id": obj["id"],
|
||||
"candidates": candidates,
|
||||
"blocking": False,
|
||||
"needs_review": False,
|
||||
}
|
||||
|
||||
if candidates:
|
||||
recommended = candidates[0]
|
||||
close_second = len(candidates) > 1 and (
|
||||
recommended["score"] - candidates[1]["score"] < 2.0
|
||||
)
|
||||
status = {
|
||||
"exakt": "exact",
|
||||
"Singular/Plural": "variant",
|
||||
"Teilwort": "partial",
|
||||
}.get(recommended["reason"], "suggested")
|
||||
needs_review = status not in {"exact", "variant"} or close_second
|
||||
return {
|
||||
"status": status,
|
||||
"requested": requested_name,
|
||||
"lookup_name": lookup_name,
|
||||
"normalized_from": normalized_from,
|
||||
"resolved": {
|
||||
"id": recommended["id"],
|
||||
"name": recommended["name"],
|
||||
},
|
||||
"selected_id": recommended["id"],
|
||||
"candidates": candidates,
|
||||
"blocking": False,
|
||||
"needs_review": needs_review,
|
||||
"message": (
|
||||
"Ähnlicher Treffer wurde vorausgewählt; bitte im Dropdown prüfen."
|
||||
if needs_review
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "missing",
|
||||
"requested": requested_name,
|
||||
"lookup_name": lookup_name,
|
||||
"normalized_from": normalized_from,
|
||||
"resolved": None,
|
||||
"selected_id": None,
|
||||
"candidates": [],
|
||||
"blocking": True,
|
||||
"needs_review": True,
|
||||
"message": (
|
||||
f"Kein passender {object_type}-Eintrag gefunden. "
|
||||
"Einen Namen eintragen und als neuen Eintrag auswählen."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def resolve_keyword(client: TandoorClient, name: str) -> dict[str, Any]:
|
||||
objects = client.list_objects("keyword")
|
||||
exact = _find_exact(objects, name)
|
||||
if exact:
|
||||
return {"id": exact["id"], "name": exact["name"]}
|
||||
return {"name": name}
|
||||
|
||||
|
||||
def amount_note(amount: float, amount_max: float | None, unit_name: str | None) -> str:
|
||||
if amount_max is None or amount_max <= amount:
|
||||
return ""
|
||||
unit = f" {unit_name}" if unit_name else ""
|
||||
return f"Mengenbereich: bis {amount_max:g}{unit}"
|
||||
|
||||
|
||||
def resolve_recipe(
|
||||
recipe: RecipeSpec,
|
||||
*,
|
||||
client: TandoorClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
client = client or TandoorClient()
|
||||
mappings: list[dict[str, Any]] = []
|
||||
steps_payload: list[dict[str, Any]] = []
|
||||
blocking = False
|
||||
|
||||
for step_index, step in enumerate(recipe.steps):
|
||||
ingredients_payload = []
|
||||
for ingredient_index, ingredient in enumerate(step.ingredients):
|
||||
food_resolution = resolve_object(
|
||||
client,
|
||||
"food",
|
||||
ingredient.food_name,
|
||||
ingredient.preferred_food_id,
|
||||
object_type="Food",
|
||||
create_requested=ingredient.create_food,
|
||||
plural_name=ingredient.food_plural_name,
|
||||
)
|
||||
unit_resolution = None
|
||||
if ingredient.unit_name:
|
||||
unit_resolution = resolve_object(
|
||||
client,
|
||||
"unit",
|
||||
ingredient.unit_name,
|
||||
ingredient.preferred_unit_id,
|
||||
object_type="Unit",
|
||||
create_requested=ingredient.create_unit,
|
||||
plural_name=ingredient.unit_plural_name,
|
||||
normalize_unit=True,
|
||||
)
|
||||
|
||||
row_blocking = food_resolution["blocking"] or bool(
|
||||
unit_resolution and unit_resolution["blocking"]
|
||||
)
|
||||
blocking = blocking or row_blocking
|
||||
mappings.append(
|
||||
{
|
||||
"step_index": step_index,
|
||||
"ingredient_index": ingredient_index,
|
||||
"step_name": step.name,
|
||||
"original_text": ingredient.original_text,
|
||||
"food": food_resolution,
|
||||
"unit": unit_resolution,
|
||||
"blocking": row_blocking,
|
||||
}
|
||||
)
|
||||
|
||||
resolved_food = food_resolution.get("resolved")
|
||||
resolved_unit = unit_resolution.get("resolved") if unit_resolution else None
|
||||
if not resolved_food or (ingredient.unit_name and not resolved_unit):
|
||||
continue
|
||||
|
||||
note_parts = [ingredient.note.strip()]
|
||||
range_note = amount_note(
|
||||
ingredient.amount,
|
||||
ingredient.amount_max,
|
||||
(
|
||||
resolved_unit.get("name")
|
||||
if isinstance(resolved_unit, dict)
|
||||
else None
|
||||
),
|
||||
)
|
||||
if range_note:
|
||||
note_parts.append(range_note)
|
||||
|
||||
ingredients_payload.append(
|
||||
{
|
||||
"food": resolved_food,
|
||||
"unit": resolved_unit,
|
||||
"amount": ingredient.amount,
|
||||
"note": "; ".join(part for part in note_parts if part),
|
||||
"order": ingredient_index,
|
||||
"is_header": False,
|
||||
"no_amount": ingredient.no_amount,
|
||||
"original_text": ingredient.original_text,
|
||||
}
|
||||
)
|
||||
|
||||
steps_payload.append(
|
||||
{
|
||||
"name": step.name,
|
||||
"instruction": step.instruction,
|
||||
"ingredients": ingredients_payload,
|
||||
"time": step.time,
|
||||
"order": step_index,
|
||||
"show_as_header": True,
|
||||
"step_recipe": None,
|
||||
"show_ingredients_table": True,
|
||||
}
|
||||
)
|
||||
|
||||
keywords = [resolve_keyword(client, name) for name in recipe.keywords]
|
||||
payload = {
|
||||
"name": recipe.name,
|
||||
"description": recipe.description,
|
||||
"keywords": keywords,
|
||||
"steps": steps_payload,
|
||||
"working_time": recipe.working_time,
|
||||
"waiting_time": recipe.waiting_time,
|
||||
"source_url": recipe.source_url,
|
||||
"internal": True,
|
||||
"show_ingredient_overview": True,
|
||||
"servings": recipe.servings,
|
||||
"servings_text": recipe.servings_text,
|
||||
"diameter": 0,
|
||||
"diameter_text": "",
|
||||
"private": False,
|
||||
"shared": [],
|
||||
}
|
||||
return {
|
||||
"blocking": blocking,
|
||||
"mappings": mappings,
|
||||
"payload": payload if not blocking else None,
|
||||
}
|
||||
|
||||
|
||||
def search_tandoor_objects(
|
||||
kind: Literal["food", "unit"],
|
||||
query: str,
|
||||
) -> dict[str, Any]:
|
||||
client = TandoorClient()
|
||||
endpoint = kind
|
||||
object_type = "Food" if kind == "food" else "Unit"
|
||||
candidates = _candidate_summary(
|
||||
client.list_objects(endpoint),
|
||||
query,
|
||||
object_type=object_type,
|
||||
limit=40,
|
||||
)
|
||||
return {
|
||||
"kind": kind,
|
||||
"query": query,
|
||||
"variants": sorted(search_variants(query)),
|
||||
"candidates": candidates,
|
||||
}
|
||||
|
||||
|
||||
def find_duplicates(
|
||||
recipe: RecipeSpec,
|
||||
*,
|
||||
client: TandoorClient | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
client = client or TandoorClient()
|
||||
payload = client.get_json(f"api/recipe/?query={quote(recipe.name)}&page_size=100")
|
||||
duplicates = []
|
||||
for overview in results_from(payload):
|
||||
recipe_id = overview.get("id")
|
||||
if recipe_id is None:
|
||||
continue
|
||||
detail = client.get_json(f"api/recipe/{recipe_id}/")
|
||||
same_name = folded(detail.get("name")) == folded(recipe.name)
|
||||
same_source = (
|
||||
str(detail.get("source_url") or "").rstrip("/")
|
||||
== recipe.source_url.rstrip("/")
|
||||
)
|
||||
if same_name or same_source:
|
||||
duplicates.append(
|
||||
{
|
||||
"id": recipe_id,
|
||||
"name": detail.get("name"),
|
||||
"source_url": detail.get("source_url"),
|
||||
}
|
||||
)
|
||||
return duplicates
|
||||
|
||||
|
||||
def _create_named_object(
|
||||
client: TandoorClient,
|
||||
endpoint: Literal["food", "unit"],
|
||||
name: str,
|
||||
plural_name: str | None,
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
# Noch einmal unmittelbar vor dem POST prüfen, damit parallele Läufe keine
|
||||
# Dublette erzeugen. Tandoor führt zusätzlich selbst get_or_create aus.
|
||||
existing = _find_exact(client.list_objects(endpoint, force_refresh=True), name)
|
||||
if existing:
|
||||
return {"id": existing["id"], "name": existing["name"]}, False
|
||||
|
||||
payload: dict[str, Any] = {"name": name.strip()}
|
||||
if plural_name and plural_name.strip():
|
||||
payload["plural_name"] = plural_name.strip()
|
||||
created = client.post_json(f"api/{endpoint}/", payload)
|
||||
if not isinstance(created, dict) or not isinstance(created.get("id"), int):
|
||||
raise RuntimeError(
|
||||
f"Tandoor hat für den neuen {endpoint}-Eintrag keine gültige ID geliefert."
|
||||
)
|
||||
client.forget_cache(endpoint)
|
||||
return {"id": created["id"], "name": created.get("name", name)}, True
|
||||
|
||||
|
||||
def _materialize_created_objects(
|
||||
client: TandoorClient,
|
||||
payload: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
output = copy.deepcopy(payload)
|
||||
created_objects: list[dict[str, Any]] = []
|
||||
memo: dict[tuple[str, str, str], dict[str, Any]] = {}
|
||||
|
||||
for step in output.get("steps", []):
|
||||
for ingredient in step.get("ingredients", []):
|
||||
for field, endpoint in (("food", "food"), ("unit", "unit")):
|
||||
obj = ingredient.get(field)
|
||||
if not isinstance(obj, dict) or not obj.get("create"):
|
||||
continue
|
||||
name = str(obj.get("name") or "").strip()
|
||||
plural_name = str(obj.get("plural_name") or "").strip() or None
|
||||
key = (endpoint, comparable(name), comparable(plural_name))
|
||||
if key not in memo:
|
||||
resolved, was_created = _create_named_object(
|
||||
client,
|
||||
endpoint, # type: ignore[arg-type]
|
||||
name,
|
||||
plural_name,
|
||||
)
|
||||
memo[key] = resolved
|
||||
if was_created:
|
||||
created_objects.append(
|
||||
{
|
||||
"endpoint": endpoint,
|
||||
"id": resolved["id"],
|
||||
"name": resolved["name"],
|
||||
}
|
||||
)
|
||||
ingredient[field] = memo[key]
|
||||
|
||||
return output, created_objects
|
||||
|
||||
|
||||
def _semantic_steps(steps: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
output = []
|
||||
for step in steps:
|
||||
ingredients = []
|
||||
for item in step.get("ingredients", []):
|
||||
food = item.get("food") or {}
|
||||
unit = item.get("unit") or {}
|
||||
ingredients.append(
|
||||
{
|
||||
"food": folded(food.get("name")),
|
||||
"unit": folded(unit.get("name")),
|
||||
"amount": round(float(item.get("amount") or 0), 8),
|
||||
"note": str(item.get("note") or "").strip(),
|
||||
"no_amount": bool(item.get("no_amount", False)),
|
||||
}
|
||||
)
|
||||
output.append(
|
||||
{
|
||||
"name": str(step.get("name") or "").strip(),
|
||||
"instruction": str(step.get("instruction") or "").strip(),
|
||||
"ingredients": ingredients,
|
||||
}
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def import_recipe(
|
||||
recipe: RecipeSpec,
|
||||
resolution: dict[str, Any],
|
||||
*,
|
||||
import_image: bool,
|
||||
force_duplicate: bool,
|
||||
) -> dict[str, Any]:
|
||||
if resolution["blocking"] or not resolution.get("payload"):
|
||||
raise RuntimeError("Der Import ist wegen ungeklärter Zuordnungen blockiert.")
|
||||
|
||||
client = TandoorClient()
|
||||
duplicates = find_duplicates(recipe, client=client)
|
||||
if duplicates and not force_duplicate:
|
||||
return {
|
||||
"status": "duplicate",
|
||||
"duplicates": duplicates,
|
||||
"recipe_id": None,
|
||||
"recipe_url": None,
|
||||
"created_objects": [],
|
||||
}
|
||||
|
||||
created_id: int | None = None
|
||||
created_objects: list[dict[str, Any]] = []
|
||||
rollback_errors: list[str] = []
|
||||
try:
|
||||
final_payload, created_objects = _materialize_created_objects(
|
||||
client,
|
||||
resolution["payload"],
|
||||
)
|
||||
created = client.post_json("api/recipe/", final_payload)
|
||||
created_id = created.get("id")
|
||||
if not isinstance(created_id, int):
|
||||
raise RuntimeError("Tandoor hat keine gültige Rezept-ID geliefert.")
|
||||
|
||||
image_result = None
|
||||
if import_image and recipe.image_url:
|
||||
image_result = client.put_image_url(created_id, recipe.image_url)
|
||||
|
||||
time.sleep(0.2)
|
||||
verified = client.get_json(f"api/recipe/{created_id}/")
|
||||
expected = _semantic_steps(final_payload["steps"])
|
||||
actual = _semantic_steps(verified.get("steps", []))
|
||||
if expected != actual:
|
||||
raise RuntimeError(
|
||||
"Die Nachprüfung der Schritte und Zutaten ist fehlgeschlagen."
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "imported",
|
||||
"recipe_id": created_id,
|
||||
"recipe_url": f"{client.base_url}/recipe/{created_id}",
|
||||
"verified": verified,
|
||||
"image_result": image_result,
|
||||
"duplicates": [],
|
||||
"created_objects": created_objects,
|
||||
}
|
||||
except Exception as original_error:
|
||||
if created_id is not None:
|
||||
try:
|
||||
client.delete(f"api/recipe/{created_id}/")
|
||||
except Exception as exc:
|
||||
rollback_errors.append(f"Rezept {created_id}: {exc}")
|
||||
|
||||
# Nur Objekte entfernen, die dieser Lauf nachweislich neu erstellt hat.
|
||||
for obj in reversed(created_objects):
|
||||
try:
|
||||
client.delete(f"api/{obj['endpoint']}/{obj['id']}/")
|
||||
except Exception as exc:
|
||||
rollback_errors.append(
|
||||
f"{obj['endpoint']} {obj['id']} ({obj['name']}): {exc}"
|
||||
)
|
||||
|
||||
if rollback_errors:
|
||||
raise RuntimeError(
|
||||
f"{original_error}\nRollback unvollständig:\n- "
|
||||
+ "\n- ".join(rollback_errors)
|
||||
) from original_error
|
||||
raise
|
||||
Reference in New Issue
Block a user