Katalogsuche und Bestätigen blockieren die UI nicht mehr; offene Namen starten bei den aktuellen Tagebucheinträgen, alter Ballast bleibt unter Alle. Co-authored-by: Cursor <cursoragent@cursor.com>
256 lines
9.2 KiB
Python
256 lines
9.2 KiB
Python
"""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)
|
|
from data_layer.food_suggest import invalidate_suggest_index
|
|
invalidate_suggest_index(profile_id)
|
|
return {
|
|
"ok": True,
|
|
"manual_foods": foods_upserted,
|
|
"mappings": mappings_ok,
|
|
"mappings_skipped": mappings_skipped,
|
|
"items_updated": items_updated,
|
|
"skipped_foods": skipped[:20],
|
|
**recipe_stats,
|
|
}
|