"""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