"""Portable JSON for user food mappings, manual foods, and FDDB lists.""" from __future__ import annotations from datetime import datetime, timezone from decimal import Decimal from typing import Any from uuid import UUID from data_layer.food_mapping import ( apply_mapping_to_items, apply_quantities_to_items, normalize_food_name, upsert_food_mapping, ) from data_layer.food_recipes import list_recipes, upsert_recipes from data_layer.nutrition_items import catalog_macros_for_item, dates_for_normalized_name, rebuild_daily_nutrients BUNDLE_FORMAT = "mitai-food-knowledge" BUNDLE_VERSION = 1 def _jsonable(value: Any) -> Any: if value is None: return None if isinstance(value, Decimal): return float(value) if isinstance(value, UUID): return str(value) if hasattr(value, "isoformat"): return value.isoformat() return value def parse_food_knowledge_bundle(data: Any) -> dict[str, Any]: if not isinstance(data, dict): raise ValueError("Die Datei ist kein JSON-Objekt") if data.get("format") != BUNDLE_FORMAT: raise ValueError("Keine Mitai-Zuordnungsdatei — bitte die exportierte JSON verwenden") try: version = int(data.get("version") or 0) except (TypeError, ValueError) as exc: raise ValueError("Unbekannte Dateiversion") from exc if version != BUNDLE_VERSION: raise ValueError(f"Nicht unterstützte Dateiversion {version}") return data def portable_mapping(row: dict[str, Any]) -> dict[str, Any]: return { "source_system": row.get("source_system") or "fddb", "source_name_raw": row.get("source_name_raw"), "source_name_normalized": row.get("source_name_normalized"), "bls_code": row.get("bls_code"), "food_name_de": row.get("food_name_de") or row.get("name_de"), "catalog_kind": row.get("catalog_kind"), "external_key": row.get("external_key"), "grams_per_unit": row.get("grams_per_unit"), "source_unit": row.get("source_unit"), } def resolve_catalog_food(cur, profile_id: str, ref: dict[str, Any]) -> str | None: bls = (ref.get("bls_code") or "").strip() if bls: cur.execute( "SELECT id FROM food_catalog WHERE bls_code = %s AND is_active = true", (bls,), ) row = cur.fetchone() return str(row["id"]) if row else None kind = ref.get("catalog_kind") or "manual_user" name = (ref.get("food_name_de") or ref.get("name_de") or "").strip() key = (ref.get("external_key") or "").strip() if kind == "manual_user": if key: cur.execute( """ SELECT id FROM food_catalog WHERE owner_profile_id = %s AND external_key = %s AND is_active = true LIMIT 1 """, (profile_id, key), ) row = cur.fetchone() if row: return str(row["id"]) if name: cur.execute( """ SELECT id FROM food_catalog WHERE owner_profile_id = %s AND catalog_kind = 'manual_user' AND lower(name_de) = lower(%s) AND is_active = true LIMIT 1 """, (profile_id, name), ) row = cur.fetchone() if row: return str(row["id"]) return None if kind == "manual_admin" and name: cur.execute( """ SELECT id FROM food_catalog WHERE catalog_kind = 'manual_admin' AND lower(name_de) = lower(%s) AND is_active = true LIMIT 1 """, (name,), ) row = cur.fetchone() return str(row["id"]) if row else None return None def export_food_knowledge(cur, profile_id: str) -> dict[str, Any]: cur.execute( """ SELECT id, name_de, name_en, catalog_kind, external_key FROM food_catalog WHERE owner_profile_id = %s AND catalog_kind = 'manual_user' AND is_active = true ORDER BY lower(name_de) """, (profile_id,), ) manuals = [] for food in cur.fetchall(): macros = catalog_macros_for_item(cur, food["id"], 100.0) manuals.append({ "name_de": food["name_de"], "name_en": food.get("name_en"), "catalog_kind": food["catalog_kind"], "external_key": food.get("external_key"), "macros_per_100g": macros, }) cur.execute( """ SELECT m.source_system, m.source_name_raw, m.source_name_normalized, m.grams_per_unit, m.source_unit, f.bls_code, f.name_de AS food_name_de, f.catalog_kind, f.external_key FROM food_name_mappings m JOIN food_catalog f ON f.id = m.food_id WHERE m.profile_id = %s ORDER BY m.source_name_normalized """, (profile_id,), ) mappings = [portable_mapping(dict(r)) for r in cur.fetchall()] recipes = [] for rec in list_recipes(cur, profile_id): recipes.append({ "name_raw": rec.get("name_raw"), "name_normalized": rec.get("name_normalized"), "portions": _jsonable(rec.get("portions")), "description": rec.get("description"), "source": rec.get("source") or "fddb_list", "ingredients": [ { "source_name_raw": ing.get("source_name_raw"), "source_name_normalized": ing.get("source_name_normalized"), "quantity_raw": ing.get("quantity_raw"), "quantity_g": _jsonable(ing.get("quantity_g")), "sort_order": ing.get("sort_order") or 0, } for ing in rec.get("ingredients") or [] ], }) return { "format": BUNDLE_FORMAT, "version": BUNDLE_VERSION, "exported_at": datetime.now(timezone.utc).isoformat(), "manual_foods": manuals, "mappings": mappings, "recipes": recipes, } def _upsert_manual_food(cur, profile_id: str, food: dict[str, Any]) -> str | None: name = (food.get("name_de") or "").strip() if not name: return None existing = resolve_catalog_food(cur, profile_id, {**food, "food_name_de": name, "catalog_kind": "manual_user"}) if existing: from routers.admin_bls import _write_manual_macros _write_manual_macros(cur, existing, food.get("macros_per_100g")) return existing key = (food.get("external_key") or "").strip() or f"man-user-{normalize_food_name(name)[:40]}" cur.execute( """ INSERT INTO food_catalog (name_de, name_en, catalog_kind, owner_profile_id, source, external_key) VALUES (%s, %s, 'manual_user', %s, 'import', %s) RETURNING id """, (name, food.get("name_en"), profile_id, key), ) food_id = str(cur.fetchone()["id"]) from routers.admin_bls import _write_manual_macros _write_manual_macros(cur, food_id, food.get("macros_per_100g")) return food_id def import_food_knowledge(cur, profile_id: str, data: dict[str, Any]) -> dict[str, Any]: bundle = parse_food_knowledge_bundle(data) foods_upserted = 0 for food in bundle.get("manual_foods") or []: if _upsert_manual_food(cur, profile_id, food): foods_upserted += 1 mappings_ok = mappings_skipped = items_updated = 0 skipped: list[str] = [] dates: set[str] = set() for raw in bundle.get("mappings") or []: name = (raw.get("source_name_raw") or "").strip() if not name: mappings_skipped += 1 continue food_id = resolve_catalog_food(cur, profile_id, raw) if not food_id: mappings_skipped += 1 label = raw.get("bls_code") or raw.get("food_name_de") or name skipped.append(str(label)) continue mid = upsert_food_mapping( cur, source_name_raw=name, food_id=food_id, profile_id=profile_id, source="import", source_system=raw.get("source_system") or "fddb", grams_per_unit=raw.get("grams_per_unit"), source_unit=raw.get("source_unit"), ) norm = normalize_food_name(name) items_updated += apply_mapping_to_items(cur, profile_id, norm, food_id, mid) apply_quantities_to_items(cur, profile_id, norm, raw.get("grams_per_unit")) dates.update(dates_for_normalized_name(cur, profile_id, norm)) mappings_ok += 1 recipe_stats = {"inserted": 0, "updated": 0, "ingredients": 0, "items_linked": 0} recipes = bundle.get("recipes") or [] if recipes: recipe_stats = upsert_recipes(cur, profile_id, recipes) dates.update(recipe_stats.pop("dates_linked", []) or []) for day in dates: rebuild_daily_nutrients(cur, profile_id, day) return { "ok": True, "manual_foods": foods_upserted, "mappings": mappings_ok, "mappings_skipped": mappings_skipped, "items_updated": items_updated, "skipped_foods": skipped[:20], **recipe_stats, }