799 lines
26 KiB
Python
799 lines
26 KiB
Python
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
|