Lebensmittel oder Rezept wählbar; gemappte Zutaten werden übernommen, offene erscheinen in der Liste. Dazu Fettgehalt-Suche, EPA-Stoffe, Rezept-CRUD und Wechsel bestehender Zuordnungen. Co-authored-by: Cursor <cursoragent@cursor.com>
292 lines
10 KiB
Python
292 lines
10 KiB
Python
"""FDDB recipe lists: upsert, link to diary items, catalog macros via ingredients."""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from typing import Any
|
|
|
|
from data_layer.food_mapping import get_food_mapping_with_cursor, normalize_food_name
|
|
from data_layer.nutrition_items import catalog_macros_for_item, _f
|
|
|
|
|
|
def upsert_recipes(cur, profile_id: str, recipes: list[dict[str, Any]]) -> dict[str, int]:
|
|
inserted = updated = ingredients = 0
|
|
for rec in recipes:
|
|
norm = rec.get("name_normalized") or normalize_food_name(rec.get("name_raw"))
|
|
if not norm:
|
|
continue
|
|
cur.execute(
|
|
"SELECT id FROM food_recipes WHERE profile_id = %s AND name_normalized = %s",
|
|
(profile_id, norm),
|
|
)
|
|
row = cur.fetchone()
|
|
if row:
|
|
rid = row["id"]
|
|
cur.execute(
|
|
"""
|
|
UPDATE food_recipes
|
|
SET name_raw=%s, portions=%s, description=%s, updated_at=NOW()
|
|
WHERE id=%s
|
|
""",
|
|
(rec["name_raw"], rec.get("portions") or 1, rec.get("description"), rid),
|
|
)
|
|
cur.execute("DELETE FROM food_recipe_ingredients WHERE recipe_id = %s", (rid,))
|
|
updated += 1
|
|
else:
|
|
rid = str(uuid.uuid4())
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO food_recipes
|
|
(id, profile_id, name_raw, name_normalized, portions, description, source)
|
|
VALUES (%s,%s,%s,%s,%s,%s,'fddb_list')
|
|
""",
|
|
(rid, profile_id, rec["name_raw"], norm, rec.get("portions") or 1, rec.get("description")),
|
|
)
|
|
inserted += 1
|
|
for ing in rec.get("ingredients") or []:
|
|
inorm = ing.get("source_name_normalized") or normalize_food_name(ing.get("source_name_raw"))
|
|
if not inorm:
|
|
continue
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO food_recipe_ingredients
|
|
(id, recipe_id, source_name_raw, source_name_normalized,
|
|
quantity_raw, quantity_g, sort_order)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
|
""",
|
|
(
|
|
str(uuid.uuid4()), rid, ing["source_name_raw"], inorm,
|
|
ing.get("quantity_raw"), ing.get("quantity_g"), ing.get("sort_order") or 0,
|
|
),
|
|
)
|
|
ingredients += 1
|
|
linked, dates = link_recipes_to_items(cur, profile_id)
|
|
return {
|
|
"inserted": inserted,
|
|
"updated": updated,
|
|
"ingredients": ingredients,
|
|
"items_linked": linked,
|
|
"dates_linked": dates,
|
|
}
|
|
|
|
|
|
def link_recipes_to_items(cur, profile_id: str) -> tuple[int, list[str]]:
|
|
cur.execute(
|
|
"""
|
|
SELECT DISTINCT i.date::text AS date
|
|
FROM nutrition_items i
|
|
JOIN food_recipes r ON r.profile_id = i.profile_id
|
|
AND i.source_name_normalized = r.name_normalized
|
|
WHERE i.profile_id = %s AND i.food_id IS NULL
|
|
""",
|
|
(profile_id,),
|
|
)
|
|
dates = [r["date"] for r in cur.fetchall()]
|
|
cur.execute(
|
|
"""
|
|
UPDATE nutrition_items i
|
|
SET recipe_id = r.id, updated_at = NOW()
|
|
FROM food_recipes r
|
|
WHERE i.profile_id = %s AND r.profile_id = %s
|
|
AND i.source_name_normalized = r.name_normalized
|
|
AND i.food_id IS NULL
|
|
""",
|
|
(profile_id, profile_id),
|
|
)
|
|
return cur.rowcount or 0, dates
|
|
|
|
|
|
def list_recipes(cur, profile_id: str) -> list[dict[str, Any]]:
|
|
cur.execute(
|
|
"""
|
|
SELECT id, name_raw, name_normalized, portions, description, source
|
|
FROM food_recipes
|
|
WHERE profile_id = %s
|
|
ORDER BY name_normalized
|
|
""",
|
|
(profile_id,),
|
|
)
|
|
recipes = [dict(r) for r in cur.fetchall()]
|
|
if not recipes:
|
|
return []
|
|
ids = [str(r["id"]) for r in recipes]
|
|
cur.execute(
|
|
"""
|
|
SELECT recipe_id, source_name_raw, source_name_normalized, quantity_raw, quantity_g, sort_order
|
|
FROM food_recipe_ingredients
|
|
WHERE recipe_id = ANY(%s::uuid[])
|
|
ORDER BY sort_order, source_name_raw
|
|
""",
|
|
(ids,),
|
|
)
|
|
by_r: dict[str, list] = {str(i): [] for i in ids}
|
|
for row in cur.fetchall():
|
|
by_r.setdefault(str(row["recipe_id"]), []).append(dict(row))
|
|
for rec in recipes:
|
|
rec["id"] = str(rec["id"])
|
|
rec["ingredients"] = by_r.get(rec["id"], [])
|
|
return recipes
|
|
|
|
|
|
def get_recipe(cur, profile_id: str, recipe_id: str) -> dict[str, Any] | None:
|
|
for rec in list_recipes(cur, profile_id):
|
|
if rec["id"] == str(recipe_id):
|
|
return rec
|
|
return None
|
|
|
|
|
|
def save_recipe(cur, profile_id: str, rec: dict[str, Any], recipe_id: str | None = None) -> dict[str, Any]:
|
|
name_raw = (rec.get("name_raw") or "").strip()
|
|
norm = rec.get("name_normalized") or normalize_food_name(name_raw)
|
|
if not name_raw or not norm:
|
|
raise ValueError("Rezeptname fehlt")
|
|
try:
|
|
portions = float(rec.get("portions") or 1)
|
|
except (TypeError, ValueError):
|
|
portions = 1.0
|
|
if portions <= 0:
|
|
portions = 1.0
|
|
description = rec.get("description")
|
|
source = (rec.get("source") or "manual").strip() or "manual"
|
|
if recipe_id:
|
|
cur.execute(
|
|
"SELECT id FROM food_recipes WHERE id = %s AND profile_id = %s",
|
|
(recipe_id, profile_id),
|
|
)
|
|
if not cur.fetchone():
|
|
raise KeyError("Rezept nicht gefunden")
|
|
cur.execute(
|
|
"""
|
|
SELECT id FROM food_recipes
|
|
WHERE profile_id = %s AND name_normalized = %s AND id <> %s
|
|
""",
|
|
(profile_id, norm, recipe_id),
|
|
)
|
|
if cur.fetchone():
|
|
raise ValueError("Ein Rezept mit diesem Namen existiert bereits")
|
|
cur.execute(
|
|
"""
|
|
UPDATE food_recipes
|
|
SET name_raw=%s, name_normalized=%s, portions=%s, description=%s, updated_at=NOW()
|
|
WHERE id=%s AND profile_id=%s
|
|
""",
|
|
(name_raw, norm, portions, description, recipe_id, profile_id),
|
|
)
|
|
cur.execute("DELETE FROM food_recipe_ingredients WHERE recipe_id = %s", (recipe_id,))
|
|
rid = recipe_id
|
|
else:
|
|
cur.execute(
|
|
"SELECT id FROM food_recipes WHERE profile_id = %s AND name_normalized = %s",
|
|
(profile_id, norm),
|
|
)
|
|
existing = cur.fetchone()
|
|
if existing:
|
|
return save_recipe(cur, profile_id, rec, str(existing["id"]))
|
|
rid = str(uuid.uuid4())
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO food_recipes
|
|
(id, profile_id, name_raw, name_normalized, portions, description, source)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
|
""",
|
|
(rid, profile_id, name_raw, norm, portions, description, source),
|
|
)
|
|
for i, ing in enumerate(rec.get("ingredients") or []):
|
|
raw = (ing.get("source_name_raw") or "").strip()
|
|
inorm = ing.get("source_name_normalized") or normalize_food_name(raw)
|
|
if not inorm:
|
|
continue
|
|
qty = ing.get("quantity_g")
|
|
try:
|
|
qty = float(qty) if qty not in (None, "") else None
|
|
except (TypeError, ValueError):
|
|
qty = None
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO food_recipe_ingredients
|
|
(id, recipe_id, source_name_raw, source_name_normalized,
|
|
quantity_raw, quantity_g, sort_order)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
|
""",
|
|
(str(uuid.uuid4()), rid, raw or inorm, inorm, ing.get("quantity_raw"), qty, i),
|
|
)
|
|
link_recipes_to_items(cur, profile_id)
|
|
saved = get_recipe(cur, profile_id, rid)
|
|
if not saved:
|
|
raise ValueError("Rezept konnte nicht gelesen werden")
|
|
return saved
|
|
|
|
|
|
def delete_recipe(cur, profile_id: str, recipe_id: str) -> None:
|
|
cur.execute(
|
|
"DELETE FROM food_recipes WHERE id = %s AND profile_id = %s RETURNING id",
|
|
(recipe_id, profile_id),
|
|
)
|
|
if not cur.fetchone():
|
|
raise KeyError("Rezept nicht gefunden")
|
|
|
|
|
|
def apply_recipe_to_items(cur, profile_id: str, source_name_normalized: str, recipe_id: str) -> int:
|
|
cur.execute(
|
|
"""
|
|
UPDATE nutrition_items
|
|
SET recipe_id = %s, food_id = NULL, mapping_id = NULL, value_origin = 'fddb', updated_at = NOW()
|
|
WHERE profile_id = %s AND source_name_normalized = %s
|
|
""",
|
|
(recipe_id, profile_id, source_name_normalized),
|
|
)
|
|
return cur.rowcount or 0
|
|
|
|
|
|
def mapped_ingredient_quantities(
|
|
cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None
|
|
) -> list[dict[str, Any]] | None:
|
|
cur.execute(
|
|
"SELECT portions FROM food_recipes WHERE id = %s AND profile_id = %s",
|
|
(recipe_id, profile_id),
|
|
)
|
|
rec = cur.fetchone()
|
|
if not rec:
|
|
return None
|
|
cur.execute(
|
|
"""
|
|
SELECT source_name_raw, quantity_g
|
|
FROM food_recipe_ingredients
|
|
WHERE recipe_id = %s
|
|
ORDER BY sort_order
|
|
""",
|
|
(recipe_id,),
|
|
)
|
|
ings = cur.fetchall()
|
|
if not ings:
|
|
return None
|
|
total_g = sum(_f(i.get("quantity_g")) for i in ings)
|
|
if eaten_qty_g and eaten_qty_g > 0 and total_g > 0:
|
|
scale = float(eaten_qty_g) / total_g
|
|
else:
|
|
portions = float(rec.get("portions") or 1) or 1.0
|
|
scale = 1.0 / portions
|
|
out = []
|
|
for ing in ings:
|
|
mapping = get_food_mapping_with_cursor(cur, ing["source_name_raw"], profile_id)
|
|
if not mapping:
|
|
return None
|
|
out.append({
|
|
"food_id": mapping["food_id"],
|
|
"quantity_g": _f(ing.get("quantity_g")) * scale,
|
|
})
|
|
return out
|
|
|
|
|
|
def catalog_macros_for_recipe(cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None) -> dict[str, float] | None:
|
|
parts = mapped_ingredient_quantities(cur, profile_id, recipe_id, eaten_qty_g)
|
|
if not parts:
|
|
return None
|
|
acc = {"kcal": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0}
|
|
for part in parts:
|
|
cat = catalog_macros_for_item(cur, part["food_id"], part["quantity_g"])
|
|
if not cat:
|
|
return None
|
|
for k in acc:
|
|
acc[k] += cat[k]
|
|
return acc
|