fix: Offene Lebensmittel nach Listen-Import wieder anzeigen
Unmapped ohne Kreuzjoin, Listen-GET kurz, Rebuild im Hintergrund. Tandoor-Zugang (URL/Token) in den Einstellungen mit ausliefern. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
fa542e0fb9
commit
d6838abda7
|
|
@ -8,7 +8,7 @@ Optionale Grundlage für verlässliche Nährwerte: offizieller Bundeslebensmitte
|
|||
|
||||
## Zuordnung (UX)
|
||||
|
||||
Offene Zuordnungen zeigen **Vorschläge in der Zeile** (z. B. Haferflocken → Hafer Flocken); Bestätigen ohne Dialog. Mehrere nahe Treffer werden gekennzeichnet. Fehlt ein Treffer, **Lebensmittel oder Liste**. Ein Lebensmittel wird ein Katalogeintrag. Eine **Liste / Kombination** öffnet den Dialog: Zutaten per Suche aus bereits gemappten Namen oder dem Katalog (Fettgehalt wie Joghurt 10 %). Jede Zutat hat **Menge + Einheit** (g, ml, EL, TL, Prise, Stück, …). Standardeinheit für Nährwerte ist Gramm; Haushaltsmaße werden umgerechnet (EL 15 g, TL 5 g, Prise 0,3 g, oder Gramm pro Stück am Mapping). Fehlt der Faktor, bleibt die Einheit erhalten und ist später umrechenbar. Katalogtreffer werden beim Speichern zugeordnet. Freitext-Zutaten bleiben offen und erscheinen in der Offene-Liste (Kennzeichnung „Listenzutat“) — dort nur als Lebensmittel zuordenbar. Nicht-Gramm-Einheiten (Stück, EL, TL, …) bekommen ein **Gramm-pro-Einheit**-Feld am Mapping. Vorschläge und Katalogsuche laufen nur für die sichtbare Arbeit. Die Offene-Liste startet bei den **letzten 4 Wochen**; ältere Namen bleiben unter „Alle“.
|
||||
Offene Zuordnungen zeigen **Vorschläge in der Zeile** (z. B. Haferflocken → Hafer Flocken); Bestätigen ohne Dialog. Mehrere nahe Treffer werden gekennzeichnet. Fehlt ein Treffer, **Lebensmittel oder Liste**. Ein Lebensmittel wird ein Katalogeintrag. Eine **Liste / Kombination** öffnet den Dialog: Zutaten per Suche aus bereits gemappten Namen oder dem Katalog (Fettgehalt wie Joghurt 10 %). Jede Zutat hat **Menge + Einheit** (g, ml, EL, TL, Prise, Stück, …). Standardeinheit für Nährwerte ist Gramm; Haushaltsmaße werden umgerechnet (EL 15 g, TL 5 g, Prise 0,3 g, oder Gramm pro Stück am Mapping). Fehlt der Faktor, bleibt die Einheit erhalten und ist später umrechenbar. Katalogtreffer werden beim Speichern zugeordnet. Freitext-Zutaten bleiben offen und erscheinen in der Offene-Liste (Kennzeichnung „Listenzutat“) — dort nur als Lebensmittel zuordenbar. Nicht-Gramm-Einheiten (Stück, EL, TL, …) bekommen ein **Gramm-pro-Einheit**-Feld am Mapping. Vorschläge und Katalogsuche laufen nur für die sichtbare Arbeit. Die Offene-Liste startet bei den **letzten 4 Wochen**; ältere Namen bleiben unter „Alle“. Offene Listenzutaten zählen mit dem Datum der letzten Listen-Nutzung oder der letzten Listenänderung — nicht nur mit einem Tagebuch-Datum der Zutat selbst. FDDB-Listenimport speichert zuerst; Tagesnährwerte folgen im Hintergrund.
|
||||
|
||||
## Listen / Kombinationen (Mitai) vs. Gerichte (Tandoor)
|
||||
|
||||
|
|
@ -52,4 +52,4 @@ Weitere Quellen (USDA, Schweizer Nährwertdatenbank) kommen später als zusätzl
|
|||
|
||||
Platzhalter (Registry) für Mikros, Esszeitpunkte (`logged_at`), Fasten; Bezug Gitea #106 (Grundlage) und #75 (Folge).
|
||||
|
||||
**Tandoor:** Familienrezepte bleiben dort. Später: Import/Verknüpfung, Zutaten gegen Mitai-Mappings, `cooked_yield_g`, Katalogeintrag Fertiggericht pro 100 g. Kein Connector in Phase 1.
|
||||
**Tandoor:** Familienrezepte bleiben dort. Persönlicher Zugang (URL + API-Token) unter Einstellungen — nicht systemweit; Token nur auf dem Server, nie im Export. Verbindungstest: `GET /api/user/` (Fallback Rezeptliste). Zutaten werden später live gelesen und auf BLS gemappt, kein Zutaten-Sync. Noch kein Rezeptimport; später `cooked_yield_g` und Katalogeintrag Fertiggericht pro 100 g.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# BLS Food Reference – technische Spec
|
||||
|
||||
**Stand:** 2026-09-12 · Migration **062** + **063**
|
||||
**Stand:** 2026-09-12 · Migration **062**–**068**
|
||||
|
||||
## Tabellen
|
||||
|
||||
|
|
@ -36,9 +36,11 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy /
|
|||
- `GET/POST /api/nutrition/recipes`, `PUT/DELETE /api/nutrition/recipes/{id}`, `POST …/import-fddb-lists`, `POST …/{id}/apply`
|
||||
- `PUT /api/admin/food-mappings/{id}` — Ziel-Lebensmittel einer bestehenden Zuordnung wechseln
|
||||
- Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Listenzutaten ohne Mapping
|
||||
- Frontend: Inline-Vorschläge auf Zuordnen (`food_suggest.py`: Collapse-Key ohne Leerzeichen, Index 5 Min. Cache, Fett-%-Zahlen 9,5/10), `FoodSearchModal` nur noch Zusatzsuche (Abort + Debounce). Listen-UI: `FoodRecipeDialog` (Suche gelernt + Katalog; Katalogpick upsertet Mapping). Offene Zutaten: `kind=recipe_ingredient` (API-Wert unverändert, UI: Listenzutat). Tandoor-Import und `cooked_yield_g` später, kein Connector in Phase 1.
|
||||
- Frontend: Inline-Vorschläge auf Zuordnen (`food_suggest.py`: Collapse-Key ohne Leerzeichen, Index 5 Min. Cache, Fett-%-Zahlen 9,5/10), `FoodSearchModal` nur noch Zusatzsuche (Abort + Debounce). Listen-UI: `FoodRecipeDialog` (Suche gelernt + Katalog; Katalogpick upsertet Mapping). Offene Zutaten: `kind=recipe_ingredient` (API-Wert unverändert, UI: Listenzutat).
|
||||
- Tandoor (Modul `tandoor`): Migration **067** `profile_tandoor_settings`; `GET/PUT /api/tandoor/settings` (Token nur `token_set` / `token_hint`); `POST /api/tandoor/test` (10/min, kein Redirect-Follow). Client: `data_layer/tandoor_client.py`. Import und `cooked_yield_g` später.
|
||||
- Migration **066** — `food_recipe_ingredients.quantity_amount` + `source_unit`; `quantity_g` = Umrechnung (`resolve_quantity` in `food_mapping.py`). `GET /bls/units` = Einheitenkatalog (EL/TL/Prise/Stück, default_g).
|
||||
- `GET /nutrition/unmapped?since_days=28` — nur Namen mit `last_date` im Fenster; `count_only` liefert `{count, total, since_days}`; `POST /bls/foods/suggest-batch` für sichtbare Zeilen (max. 80)
|
||||
- `GET /nutrition/unmapped?since_days=28` — nur Namen mit `last_date` im Fenster; `count_only` liefert `{count, total, since_days}`; `meta=true` liefert `{items, count, total, since_days}`. Listenzutaten-Datum: letzte `recipe_id`-Nutzung oder `food_recipes.updated_at`/`created_at` — kein Join über den Listennamen auf alle Tagebuchzeilen. Migration **068**: Partial-Index unmapped-open + `(profile_id, recipe_id, date)`.
|
||||
- `GET /nutrition/recipes?brief=true` — Listen ohne Zutaten, nur `ingredient_count`. `GET /nutrition/recipes/{id}` lädt eine Liste. FDDB-Listenimport antwortet nach Upsert; `rebuild_daily_nutrients` im Hintergrund (`rebuild_pending`).
|
||||
- Mapping-Schreiben und Nährwert-Rebuild sind getrennte Transaktionen; Rebuild läuft nach der API-Antwort im Hintergrund (UI bleibt bedienbar)
|
||||
- `food_name_mappings.grams_per_unit` / `source_unit` (Migration **064**)
|
||||
- `GET/POST /api/nutrition/food-knowledge` — portable JSON (`mitai-food-knowledge` v1): manuelle Foods, Mappings (über `bls_code` / Name, keine UUIDs), Listen. Import löst Katalog auf dem Zielsystem auf (BLS muss dort importiert sein).
|
||||
|
|
|
|||
|
|
@ -176,6 +176,14 @@ known_limitations=(
|
|||
**Problem:** Versuch, `.claude/task/` Files zu committen
|
||||
**Lösung:** Nur `backend/` Code committen, `.claude/` ist local docs
|
||||
|
||||
## 9. Unmapped-Query: Kreuzjoin Liste × Tagebuch (12.09.2026)
|
||||
|
||||
**Was:** Offene Lebensmittel über `food_recipes` mit `nutrition_items` per `recipe_id OR name_normalized` verbinden, um Zutaten ein Datum zu geben.
|
||||
**Problem:** Nach FDDB-Listenimport Minuten bis Timeout; Zutaten ohne `last_date` fielen im 28-Tage-Filter weg — leere Offene-Liste.
|
||||
**Lösung:** Nur Items mit `recipe_id` gruppieren; Fallback `recipe.updated_at`. Listen-GET `brief=true`. Rebuild nach Import im Hintergrund.
|
||||
|
||||
**Regel:** Kein Join „Name der Liste gegen alle Tagebuchzeilen“. Datum aus der bereits gesetzten FK oder aus dem Eltern-Datensatz.
|
||||
|
||||
---
|
||||
|
||||
**Zusammenfassung Nutrition Cluster:**
|
||||
|
|
|
|||
|
|
@ -122,11 +122,11 @@ frontend/src/
|
|||
- **Migration 062:** `food_catalog` (BLS-Code bleibt Identität), dynamische `food_attributes` + EAV, `food_name_mappings`, `nutrition_items`, `nutrition_daily_nutrients`, `nutrition_day_marks`, Import-Policy am Profil.
|
||||
- **Admin:** Gruppe Ernährung — BLS-Import, Katalog, Attribute, Mappings.
|
||||
- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen mit Namenssuche (Popup); FDDB-Listen/Kombinationen; JSON-Export/Import der Zuordnungen; Fasten/Lücke; Import-Abgleich.
|
||||
- **Zuordnen-Performance:** Katalog-Index im Prozess (5 Min.), Vorschläge nur für sichtbare Zeilen (`POST /bls/foods/suggest-batch`), Suche mit Abort; nach Bestätigen kein Reload der ganzen Ernährungseite.
|
||||
- **Zuordnen-Performance:** Katalog-Index im Prozess (5 Min.), Vorschläge nur für sichtbare Zeilen (`POST /bls/foods/suggest-batch`), Suche mit Abort; nach Bestätigen kein Reload der ganzen Ernährungseite. Unmapped ohne Kreuzjoin Liste×Tagebuch; Listenzutaten-Datum aus `recipe.updated_at` oder letzter `recipe_id`-Nutzung. Listen-API `?brief=true`; FDDB-Listenimport rechnet Tageswerte im Hintergrund. Migration **068**.
|
||||
- **Zuordnen-Zeitraum:** Standard letzte 4 Wochen (`since_days`); ältere ungemappte Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“.
|
||||
- **Katalogsuche:** Fettgehalt mitsuchen (`Joghurt 10%` / `9,5`); Dezimal-Komma bleibt erhalten.
|
||||
- **Manuelle Foods:** Stoffe über EAV (`GET /bls/attributes`, `attributes` + `serving_g` beim Anlegen). Supplemente wie Norsan: EPA/DHA aus Etikett, Portionsgramm → Speicherung /100 g.
|
||||
- **Listen / Kombinationen (Mitai):** Dialog mit Zutatensuche (Mappings + Katalog). Menge + Einheit; Umrechnung auf Gramm. Rohmischung ohne Kochschwund. Gekochte Familienrezepte: Tandoor + später `cooked_yield_g` / Fertiggericht pro 100 g. Admin-Mappings: `PUT /admin/food-mappings/{id}`.
|
||||
- **Listen / Kombinationen (Mitai):** Dialog mit Zutatensuche (Mappings + Katalog). Menge + Einheit; Umrechnung auf Gramm. Rohmischung ohne Kochschwund. **Tandoor:** User-Einstellungen URL + Token, Verbindungstest (`/api/tandoor/*`). Gerichte bleiben in Tandoor; später Mapping + `cooked_yield_g`. Admin-Mappings: `PUT /admin/food-mappings/{id}`.
|
||||
- **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106
|
||||
- **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75.
|
||||
|
||||
|
|
|
|||
|
|
@ -99,6 +99,91 @@ def filter_unmapped_since(rows: list[dict], since_days: int, today: date | None
|
|||
return out
|
||||
|
||||
|
||||
def fetch_unmapped_rows(cur, profile_id: str) -> list[dict[str, Any]]:
|
||||
"""Diary lines without food/recipe plus list ingredients without mapping.
|
||||
|
||||
Ingredient dates come from recipe.updated_at or last diary use of that list
|
||||
(items that already have recipe_id) — not a join of every recipe name to
|
||||
every diary row.
|
||||
"""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT source_name_normalized
|
||||
FROM food_name_mappings
|
||||
WHERE profile_id = %s
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
mapped = {r["source_name_normalized"] for r in cur.fetchall()}
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT i.source_name_raw, i.source_name_normalized,
|
||||
COUNT(*) AS count, MIN(i.date) AS first_date, MAX(i.date) AS last_date,
|
||||
MIN(r.id::text) AS matching_recipe_id,
|
||||
MIN(i.quantity_raw) AS sample_quantity_raw
|
||||
FROM nutrition_items i
|
||||
LEFT JOIN food_recipes r
|
||||
ON r.profile_id = i.profile_id AND r.name_normalized = i.source_name_normalized
|
||||
WHERE i.profile_id = %s AND i.food_id IS NULL AND i.recipe_id IS NULL
|
||||
GROUP BY i.source_name_raw, i.source_name_normalized
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
diary = [dict(r) | {"kind": "diary"} for r in cur.fetchall()]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT i.source_name_raw, i.source_name_normalized,
|
||||
COUNT(*) AS count,
|
||||
MIN(COALESCE(u.last_used, r.updated_at::date, r.created_at::date)) AS first_date,
|
||||
MAX(COALESCE(u.last_used, r.updated_at::date, r.created_at::date)) AS last_date,
|
||||
MIN(i.quantity_raw) AS sample_quantity_raw
|
||||
FROM food_recipe_ingredients i
|
||||
JOIN food_recipes r ON r.id = i.recipe_id
|
||||
LEFT JOIN (
|
||||
SELECT recipe_id, MIN(date) AS first_used, MAX(date) AS last_used
|
||||
FROM nutrition_items
|
||||
WHERE profile_id = %s AND recipe_id IS NOT NULL
|
||||
GROUP BY recipe_id
|
||||
) u ON u.recipe_id = r.id
|
||||
WHERE r.profile_id = %s
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM food_name_mappings m
|
||||
WHERE m.profile_id = r.profile_id
|
||||
AND m.source_name_normalized = i.source_name_normalized
|
||||
)
|
||||
GROUP BY i.source_name_raw, i.source_name_normalized
|
||||
""",
|
||||
(profile_id, profile_id),
|
||||
)
|
||||
ings = [dict(r) | {"kind": "recipe_ingredient"} for r in cur.fetchall()]
|
||||
out = []
|
||||
for row in merge_unmapped_rows(diary + ings):
|
||||
key = row.get("source_name_normalized") or normalize_food_name(row.get("source_name_raw"))
|
||||
if not key or key in mapped:
|
||||
continue
|
||||
row["last_date"] = as_iso_date(row.get("last_date"))
|
||||
row["first_date"] = as_iso_date(row.get("first_date"))
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def list_unmapped_payload(
|
||||
cur,
|
||||
profile_id: str,
|
||||
since_days: int = 0,
|
||||
count_only: bool = False,
|
||||
meta: bool = False,
|
||||
) -> Any:
|
||||
days = max(0, min(int(since_days or 0), 3650))
|
||||
out = fetch_unmapped_rows(cur, profile_id)
|
||||
items = sort_unmapped_rows(filter_unmapped_since(out, days) if days else out, days)
|
||||
if count_only:
|
||||
return {"count": len(items), "total": len(out), "since_days": days}
|
||||
if meta:
|
||||
return {"items": items, "count": len(items), "total": len(out), "since_days": days}
|
||||
return items
|
||||
|
||||
|
||||
def sort_unmapped_rows(rows: list[dict], since_days: int = 0) -> list[dict]:
|
||||
rows = list(rows)
|
||||
if int(since_days or 0) > 0:
|
||||
|
|
|
|||
|
|
@ -69,7 +69,12 @@ def upsert_recipes(cur, profile_id: str, recipes: list[dict[str, Any]]) -> dict[
|
|||
if not inorm:
|
||||
continue
|
||||
raw_name = ing.get("source_name_raw") or inorm
|
||||
qty = _resolved_ingredient_qty(cur, profile_id, raw_name, ing)
|
||||
qty = resolve_quantity(
|
||||
quantity_raw=ing.get("quantity_raw"),
|
||||
quantity_amount=ing.get("quantity_amount"),
|
||||
source_unit=ing.get("source_unit"),
|
||||
quantity_g=ing.get("quantity_g"),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_recipe_ingredients
|
||||
|
|
@ -120,7 +125,7 @@ def link_recipes_to_items(cur, profile_id: str) -> tuple[int, list[str]]:
|
|||
return cur.rowcount or 0, dates
|
||||
|
||||
|
||||
def list_recipes(cur, profile_id: str) -> list[dict[str, Any]]:
|
||||
def list_recipes(cur, profile_id: str, with_ingredients: bool = True) -> list[dict[str, Any]]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name_raw, name_normalized, portions, description, source
|
||||
|
|
@ -134,6 +139,33 @@ def list_recipes(cur, profile_id: str) -> list[dict[str, Any]]:
|
|||
if not recipes:
|
||||
return []
|
||||
ids = [str(r["id"]) for r in recipes]
|
||||
if not with_ingredients:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT recipe_id, COUNT(*) AS n
|
||||
FROM food_recipe_ingredients
|
||||
WHERE recipe_id = ANY(%s::uuid[])
|
||||
GROUP BY recipe_id
|
||||
""",
|
||||
(ids,),
|
||||
)
|
||||
counts = {str(r["recipe_id"]): int(r["n"]) for r in cur.fetchall()}
|
||||
for rec in recipes:
|
||||
rec["id"] = str(rec["id"])
|
||||
rec["ingredient_count"] = counts.get(rec["id"], 0)
|
||||
rec["ingredients"] = []
|
||||
return recipes
|
||||
by_r = _ingredients_by_recipe(cur, ids)
|
||||
for rec in recipes:
|
||||
rec["id"] = str(rec["id"])
|
||||
rec["ingredients"] = by_r.get(rec["id"], [])
|
||||
rec["ingredient_count"] = len(rec["ingredients"])
|
||||
return recipes
|
||||
|
||||
|
||||
def _ingredients_by_recipe(cur, recipe_ids: list[str]) -> dict[str, list]:
|
||||
if not recipe_ids:
|
||||
return {}
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT recipe_id, source_name_raw, source_name_normalized,
|
||||
|
|
@ -142,22 +174,31 @@ def list_recipes(cur, profile_id: str) -> list[dict[str, Any]]:
|
|||
WHERE recipe_id = ANY(%s::uuid[])
|
||||
ORDER BY sort_order, source_name_raw
|
||||
""",
|
||||
(ids,),
|
||||
(recipe_ids,),
|
||||
)
|
||||
by_r: dict[str, list] = {str(i): [] for i in ids}
|
||||
by_r: dict[str, list] = {str(i): [] for i in recipe_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
|
||||
return by_r
|
||||
|
||||
|
||||
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
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name_raw, name_normalized, portions, description, source
|
||||
FROM food_recipes
|
||||
WHERE profile_id = %s AND id = %s
|
||||
""",
|
||||
(profile_id, recipe_id),
|
||||
)
|
||||
rec = cur.fetchone()
|
||||
if not rec:
|
||||
return None
|
||||
rec = dict(rec)
|
||||
rec["id"] = str(rec["id"])
|
||||
rec["ingredients"] = _ingredients_by_recipe(cur, [rec["id"]]).get(rec["id"], [])
|
||||
rec["ingredient_count"] = len(rec["ingredients"])
|
||||
return rec
|
||||
|
||||
|
||||
def save_recipe(cur, profile_id: str, rec: dict[str, Any], recipe_id: str | None = None) -> dict[str, Any]:
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
"""Nutrition diary items, three macro sums, import policy, attribute resolve."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from data_layer.food_mapping import (
|
||||
get_food_mapping_with_cursor,
|
||||
normalize_food_name,
|
||||
|
|
@ -222,6 +226,25 @@ def _accumulate_food_qty(cur, acc: dict[int, list[float]], food_id: str, quantit
|
|||
acc.setdefault(row["attribute_id"], []).append(float(row["value_num"]) * factor)
|
||||
|
||||
|
||||
def schedule_daily_nutrient_rebuild(profile_id: str, dates: list[str], context: str) -> None:
|
||||
days = [str(d) for d in (dates or []) if d]
|
||||
if not days:
|
||||
return
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
from db import get_cursor, get_db
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
for day in days:
|
||||
rebuild_daily_nutrients(cur, profile_id, day)
|
||||
except Exception:
|
||||
logger.exception("Nährwert-Rebuild nach %s fehlgeschlagen", context)
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name="nutrition-rebuild").start()
|
||||
|
||||
|
||||
def rebuild_daily_nutrients(cur, profile_id: str, day: date | str) -> None:
|
||||
cur.execute(
|
||||
"DELETE FROM nutrition_daily_nutrients WHERE profile_id = %s AND date = %s",
|
||||
|
|
|
|||
120
backend/data_layer/tandoor_client.py
Normal file
120
backend/data_layer/tandoor_client.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"""HTTP client for a user's Tandoor instance. Never logs the token."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
|
||||
class TandoorError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def normalize_base_url(raw: str | None) -> str:
|
||||
url = (raw or "").strip().rstrip("/")
|
||||
if not url:
|
||||
raise TandoorError("Tandoor-URL fehlt")
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("https", "http") or not parsed.netloc:
|
||||
raise TandoorError("Tandoor-URL muss mit https:// oder http:// beginnen")
|
||||
if parsed.scheme == "http" and parsed.hostname not in ("localhost", "127.0.0.1"):
|
||||
raise TandoorError("Tandoor-URL muss https verwenden")
|
||||
return url
|
||||
|
||||
|
||||
def token_hint(token: str | None) -> str | None:
|
||||
t = (token or "").strip()
|
||||
if len(t) < 4:
|
||||
return "gesetzt" if t else None
|
||||
return f"…{t[-4:]}"
|
||||
|
||||
|
||||
def public_settings(row: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not row:
|
||||
return {
|
||||
"configured": False,
|
||||
"base_url": "",
|
||||
"token_set": False,
|
||||
"token_hint": None,
|
||||
"last_ok_at": None,
|
||||
"last_error": None,
|
||||
}
|
||||
return {
|
||||
"configured": True,
|
||||
"base_url": row.get("base_url") or "",
|
||||
"token_set": bool((row.get("api_token") or "").strip()),
|
||||
"token_hint": token_hint(row.get("api_token")),
|
||||
"last_ok_at": row.get("last_ok_at").isoformat() if row.get("last_ok_at") else None,
|
||||
"last_error": row.get("last_error"),
|
||||
}
|
||||
|
||||
|
||||
def _request(base_url: str, token: str, path: str, timeout: float = 10.0) -> tuple[int, str, str]:
|
||||
url = urljoin(base_url.rstrip("/") + "/", path.lstrip("/"))
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"Token {token.strip()}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "Mitai-Jinkendo/tandoor-connector",
|
||||
},
|
||||
method="GET",
|
||||
)
|
||||
ctx = ssl.create_default_context()
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ARG002
|
||||
return None
|
||||
|
||||
opener = urllib.request.build_opener(_NoRedirect, urllib.request.HTTPSHandler(context=ctx))
|
||||
try:
|
||||
with opener.open(req, timeout=timeout) as resp:
|
||||
body = resp.read().decode("utf-8", errors="replace")
|
||||
ctype = resp.headers.get("Content-Type", "")
|
||||
return resp.status, body, ctype
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace") if e.fp else ""
|
||||
ctype = e.headers.get("Content-Type", "") if e.headers else ""
|
||||
return e.code, body, ctype
|
||||
except TimeoutError as e:
|
||||
raise TandoorError("Tandoor antwortet nicht (Zeitüberschreitung)") from e
|
||||
except urllib.error.URLError as e:
|
||||
raise TandoorError(f"Tandoor nicht erreichbar: {e.reason}") from e
|
||||
|
||||
|
||||
def probe_connection(base_url: str, token: str) -> dict[str, Any]:
|
||||
url = normalize_base_url(base_url)
|
||||
tok = (token or "").strip()
|
||||
if not tok:
|
||||
raise TandoorError("API-Token fehlt")
|
||||
status, body, ctype = _request(url, tok, "api/user/")
|
||||
if status in (301, 302, 303, 307, 308) or "text/html" in (ctype or "") and status < 400:
|
||||
raise TandoorError("Tandoor hat nicht mit JSON geantwortet — URL oder Token prüfen")
|
||||
if status in (401, 403):
|
||||
raise TandoorError("Token ungültig oder ohne Rechte")
|
||||
if status == 404:
|
||||
status, body, ctype = _request(url, tok, "api/recipe/?page=1&page_size=1")
|
||||
if status in (401, 403):
|
||||
raise TandoorError("Token ungültig oder ohne Rechte")
|
||||
if status >= 400:
|
||||
raise TandoorError(f"Tandoor antwortete mit HTTP {status}")
|
||||
if "json" not in (ctype or "") and not (body or "").lstrip().startswith(("{", "[")):
|
||||
raise TandoorError("Tandoor hat nicht mit JSON geantwortet — URL oder Token prüfen")
|
||||
try:
|
||||
data = json.loads(body) if body else {}
|
||||
except json.JSONDecodeError as e:
|
||||
raise TandoorError("Tandoor-Antwort war kein JSON") from e
|
||||
name = None
|
||||
if isinstance(data, dict):
|
||||
results = data.get("results")
|
||||
if isinstance(results, list) and results and isinstance(results[0], dict):
|
||||
name = results[0].get("display_name") or results[0].get("username") or results[0].get("name")
|
||||
else:
|
||||
name = data.get("display_name") or data.get("username") or data.get("name")
|
||||
count = data.get("count")
|
||||
else:
|
||||
count = None
|
||||
return {"ok": True, "user": name, "recipe_count": count}
|
||||
67
backend/data_layer/tandoor_settings.py
Normal file
67
backend/data_layer/tandoor_settings.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""Persist per-profile Tandoor credentials. Token never returned in full."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from data_layer.tandoor_client import public_settings
|
||||
|
||||
|
||||
def get_settings(cur, profile_id: str) -> dict[str, Any] | None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT profile_id, base_url, api_token, last_ok_at, last_error, updated_at
|
||||
FROM profile_tandoor_settings
|
||||
WHERE profile_id = %s
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_public_settings(cur, profile_id: str) -> dict[str, Any]:
|
||||
return public_settings(get_settings(cur, profile_id))
|
||||
|
||||
|
||||
def upsert_settings(cur, profile_id: str, base_url: str, token: str | None, *, clear_token: bool = False) -> dict[str, Any]:
|
||||
existing = get_settings(cur, profile_id)
|
||||
if clear_token:
|
||||
cur.execute("DELETE FROM profile_tandoor_settings WHERE profile_id = %s", (profile_id,))
|
||||
return public_settings(None)
|
||||
stored = (token or "").strip() or ((existing or {}).get("api_token") or "")
|
||||
if not stored:
|
||||
raise ValueError("API-Token fehlt")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO profile_tandoor_settings (profile_id, base_url, api_token, last_error, updated_at)
|
||||
VALUES (%s, %s, %s, NULL, NOW())
|
||||
ON CONFLICT (profile_id) DO UPDATE SET
|
||||
base_url = EXCLUDED.base_url,
|
||||
api_token = EXCLUDED.api_token,
|
||||
last_error = NULL,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(profile_id, base_url, stored),
|
||||
)
|
||||
return get_public_settings(cur, profile_id)
|
||||
|
||||
|
||||
def record_probe(cur, profile_id: str, ok: bool, error: str | None = None) -> None:
|
||||
if ok:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE profile_tandoor_settings
|
||||
SET last_ok_at = NOW(), last_error = NULL, updated_at = NOW()
|
||||
WHERE profile_id = %s
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE profile_tandoor_settings
|
||||
SET last_error = %s, updated_at = NOW()
|
||||
WHERE profile_id = %s
|
||||
""",
|
||||
((error or "")[:300], profile_id),
|
||||
)
|
||||
|
|
@ -39,6 +39,7 @@ from routers import reports # Strukturierter PDF-Bericht (Profil v1)
|
|||
from routers import csv_import, admin_csv_templates # Issue #21 Universal CSV Parser
|
||||
from routers import admin_training_parameters, admin_activity_attribute_profiles # EAV session metrics
|
||||
from routers import bls, admin_bls, admin_food_mappings # BLS catalog + FDDB mapping
|
||||
from routers import tandoor # per-user Tandoor connection
|
||||
|
||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
||||
|
|
@ -137,6 +138,7 @@ app.include_router(admin_activity_attribute_profiles.router) # /api/admin/train
|
|||
app.include_router(bls.router) # /api/bls/*
|
||||
app.include_router(admin_bls.router) # /api/admin/bls/*
|
||||
app.include_router(admin_food_mappings.router) # /api/admin/food-mappings
|
||||
app.include_router(tandoor.router) # /api/tandoor/*
|
||||
|
||||
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||
@app.get("/")
|
||||
|
|
|
|||
18
backend/migrations/067_profile_tandoor_settings.sql
Normal file
18
backend/migrations/067_profile_tandoor_settings.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
-- Per-user Tandoor connection (URL + API token). Not a system-wide connector.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile_tandoor_settings (
|
||||
profile_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
base_url VARCHAR(300) NOT NULL,
|
||||
api_token TEXT NOT NULL,
|
||||
last_ok_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE profile_tandoor_settings IS 'Persönliche Tandoor-API (Familie); Token nie systemweit';
|
||||
COMMENT ON COLUMN profile_tandoor_settings.api_token IS 'Tandoor Access Token; nur Backend, nie im JSON-Export';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE 'Migration 067: profile_tandoor_settings';
|
||||
END $$;
|
||||
19
backend/migrations/068_nutrition_unmapped_indexes.sql
Normal file
19
backend/migrations/068_nutrition_unmapped_indexes.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
-- Migration 068: schnellere Unmapped-Liste und Listen-Nutzung
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_nutrition_items_unmapped_open
|
||||
ON nutrition_items (profile_id, source_name_normalized)
|
||||
WHERE food_id IS NULL AND recipe_id IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_nutrition_items_profile_recipe_date
|
||||
ON nutrition_items (profile_id, recipe_id, date)
|
||||
WHERE recipe_id IS NOT NULL;
|
||||
|
||||
COMMENT ON INDEX idx_nutrition_items_unmapped_open IS
|
||||
'Tagebuchzeilen ohne Lebensmittel und ohne Liste';
|
||||
COMMENT ON INDEX idx_nutrition_items_profile_recipe_date IS
|
||||
'Letzte Nutzung einer Liste ohne Join über den Listennamen';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE 'Migration 068: nutrition unmapped/recipe indexes';
|
||||
END $$;
|
||||
|
|
@ -366,96 +366,42 @@ def list_nutrition_items(
|
|||
def list_unmapped_foods(
|
||||
since_days: int = 0,
|
||||
count_only: bool = False,
|
||||
meta: bool = False,
|
||||
x_profile_id: Optional[str] = Header(default=None),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
from data_layer.food_mapping import (
|
||||
as_iso_date,
|
||||
filter_unmapped_since,
|
||||
merge_unmapped_rows,
|
||||
normalize_food_name,
|
||||
sort_unmapped_rows,
|
||||
)
|
||||
from data_layer.food_mapping import list_unmapped_payload
|
||||
|
||||
pid = x_profile_id or session["profile_id"]
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT source_name_normalized
|
||||
FROM food_name_mappings
|
||||
WHERE profile_id = %s
|
||||
""",
|
||||
(pid,),
|
||||
)
|
||||
mapped = {r["source_name_normalized"] for r in cur.fetchall()}
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT i.source_name_raw, i.source_name_normalized,
|
||||
COUNT(*) AS count, MIN(i.date) AS first_date, MAX(i.date) AS last_date,
|
||||
MIN(r.id::text) AS matching_recipe_id,
|
||||
MIN(i.quantity_raw) AS sample_quantity_raw
|
||||
FROM nutrition_items i
|
||||
LEFT JOIN food_recipes r
|
||||
ON r.profile_id = i.profile_id AND r.name_normalized = i.source_name_normalized
|
||||
WHERE i.profile_id=%s AND i.food_id IS NULL AND i.recipe_id IS NULL
|
||||
GROUP BY i.source_name_raw, i.source_name_normalized
|
||||
ORDER BY count DESC, i.source_name_normalized
|
||||
""",
|
||||
(pid,),
|
||||
)
|
||||
diary = [r2d(r) | {"kind": "diary"} for r in cur.fetchall()]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT i.source_name_raw, i.source_name_normalized,
|
||||
COUNT(*) AS count, u.first_used AS first_date, u.last_used AS last_date,
|
||||
MIN(i.quantity_raw) AS sample_quantity_raw
|
||||
FROM food_recipe_ingredients i
|
||||
JOIN food_recipes r ON r.id = i.recipe_id
|
||||
LEFT JOIN food_name_mappings m
|
||||
ON m.profile_id = r.profile_id
|
||||
AND m.source_name_normalized = i.source_name_normalized
|
||||
LEFT JOIN (
|
||||
SELECT r2.id AS recipe_id, MIN(ni.date) AS first_used, MAX(ni.date) AS last_used
|
||||
FROM food_recipes r2
|
||||
JOIN nutrition_items ni
|
||||
ON ni.profile_id = r2.profile_id
|
||||
AND (ni.recipe_id = r2.id OR ni.source_name_normalized = r2.name_normalized)
|
||||
WHERE r2.profile_id = %s
|
||||
GROUP BY r2.id
|
||||
) u ON u.recipe_id = r.id
|
||||
WHERE r.profile_id = %s AND m.id IS NULL
|
||||
GROUP BY i.source_name_raw, i.source_name_normalized, u.first_used, u.last_used
|
||||
ORDER BY count DESC, i.source_name_normalized
|
||||
""",
|
||||
(pid, pid),
|
||||
)
|
||||
ings = [r2d(r) | {"kind": "recipe_ingredient"} for r in cur.fetchall()]
|
||||
merged = merge_unmapped_rows(diary + ings)
|
||||
out = []
|
||||
for row in merged:
|
||||
key = row.get("source_name_normalized") or normalize_food_name(row.get("source_name_raw"))
|
||||
if key in mapped:
|
||||
continue
|
||||
row["last_date"] = as_iso_date(row.get("last_date"))
|
||||
row["first_date"] = as_iso_date(row.get("first_date"))
|
||||
out.append(row)
|
||||
days = max(0, min(int(since_days or 0), 3650))
|
||||
recent = sort_unmapped_rows(filter_unmapped_since(out, days), days)
|
||||
if count_only:
|
||||
return {"count": len(recent), "total": len(out), "since_days": days}
|
||||
return recent if days else sort_unmapped_rows(out, 0)
|
||||
return list_unmapped_payload(get_cursor(conn), pid, since_days, count_only, meta)
|
||||
|
||||
|
||||
@router.get("/recipes")
|
||||
def list_food_recipes(
|
||||
brief: bool = False,
|
||||
x_profile_id: Optional[str] = Header(default=None),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
from data_layer.food_recipes import list_recipes
|
||||
pid = get_pid(x_profile_id)
|
||||
with get_db() as conn:
|
||||
return list_recipes(get_cursor(conn), pid)
|
||||
return list_recipes(get_cursor(conn), pid, with_ingredients=not brief)
|
||||
|
||||
|
||||
@router.get("/recipes/{recipe_id}")
|
||||
def get_food_recipe(
|
||||
recipe_id: str,
|
||||
x_profile_id: Optional[str] = Header(default=None),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
from data_layer.food_recipes import get_recipe
|
||||
pid = get_pid(x_profile_id)
|
||||
with get_db() as conn:
|
||||
rec = get_recipe(get_cursor(conn), pid, recipe_id)
|
||||
if not rec:
|
||||
raise HTTPException(404, "Rezept nicht gefunden")
|
||||
return rec
|
||||
|
||||
|
||||
@router.post("/recipes")
|
||||
|
|
@ -530,10 +476,10 @@ async def import_fddb_lists(
|
|||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
stats = upsert_recipes(cur, pid, recipes)
|
||||
from data_layer.nutrition_items import rebuild_daily_nutrients
|
||||
for d in stats.pop("dates_linked", []) or []:
|
||||
rebuild_daily_nutrients(cur, pid, d)
|
||||
return {"ok": True, "recipes": len(recipes), **stats}
|
||||
dates = stats.pop("dates_linked", []) or []
|
||||
from data_layer.nutrition_items import schedule_daily_nutrient_rebuild
|
||||
schedule_daily_nutrient_rebuild(pid, dates, "FDDB-Listenimport")
|
||||
return {"ok": True, "recipes": len(recipes), "rebuild_pending": len(dates), **stats}
|
||||
|
||||
|
||||
@router.post("/recipes/{recipe_id}/apply")
|
||||
|
|
|
|||
78
backend/routers/tandoor.py
Normal file
78
backend/routers/tandoor.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""Per-user Tandoor connection. No recipe import yet."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from starlette.requests import Request
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from auth import require_auth
|
||||
from data_layer.tandoor_client import TandoorError, normalize_base_url, probe_connection
|
||||
from data_layer.tandoor_settings import (
|
||||
get_public_settings,
|
||||
get_settings,
|
||||
record_probe,
|
||||
upsert_settings,
|
||||
)
|
||||
from db import get_cursor, get_db
|
||||
|
||||
router = APIRouter(prefix="/api/tandoor", tags=["tandoor"])
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
|
||||
class TandoorSettingsIn(BaseModel):
|
||||
base_url: str = ""
|
||||
token: Optional[str] = None
|
||||
clear_token: bool = False
|
||||
|
||||
|
||||
class TandoorProbeIn(BaseModel):
|
||||
base_url: Optional[str] = None
|
||||
token: Optional[str] = Field(default=None, description="Nur zum Testen; leer = gespeichertes Token")
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
def read_settings(session: dict = Depends(require_auth)):
|
||||
pid = session["profile_id"]
|
||||
with get_db() as conn:
|
||||
return get_public_settings(get_cursor(conn), pid)
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
def write_settings(body: TandoorSettingsIn, session: dict = Depends(require_auth)):
|
||||
pid = session["profile_id"]
|
||||
try:
|
||||
if body.clear_token:
|
||||
url = ""
|
||||
else:
|
||||
url = normalize_base_url(body.base_url)
|
||||
with get_db() as conn:
|
||||
return upsert_settings(
|
||||
get_cursor(conn), pid, url, body.token, clear_token=body.clear_token
|
||||
)
|
||||
except (TandoorError, ValueError) as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
@limiter.limit("10/minute")
|
||||
def test_connection(request: Request, body: TandoorProbeIn | None = None, session: dict = Depends(require_auth)):
|
||||
pid = session["profile_id"]
|
||||
body = body or TandoorProbeIn()
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
stored = get_settings(cur, pid)
|
||||
url = (body.base_url or "").strip() or ((stored or {}).get("base_url") or "")
|
||||
token = (body.token or "").strip() or ((stored or {}).get("api_token") or "")
|
||||
try:
|
||||
result = probe_connection(url, token)
|
||||
if stored:
|
||||
record_probe(cur, pid, True)
|
||||
return result
|
||||
except TandoorError as e:
|
||||
if stored:
|
||||
record_probe(cur, pid, False, str(e))
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
|
@ -2,8 +2,10 @@ from datetime import date
|
|||
|
||||
from csv_parser.executor import guess_nutrition_item_fields
|
||||
from data_layer.food_mapping import (
|
||||
fetch_unmapped_rows,
|
||||
filter_unmapped_since,
|
||||
list_quantity_units,
|
||||
list_unmapped_payload,
|
||||
merge_unmapped_rows,
|
||||
normalize_food_name,
|
||||
parse_quantity,
|
||||
|
|
@ -84,6 +86,68 @@ def test_recent_window_drops_old_and_dateless_foods():
|
|||
assert len(filter_unmapped_since(rows, 0, today=today)) == 3
|
||||
|
||||
|
||||
class _FakeCur:
|
||||
def __init__(self, batches):
|
||||
self.batches = list(batches)
|
||||
self._i = 0
|
||||
self._rows = []
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
self._rows = self.batches[self._i]
|
||||
self._i += 1
|
||||
|
||||
def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
|
||||
def test_fetch_unmapped_keeps_ingredient_dates_and_skips_mapped():
|
||||
cur = _FakeCur([
|
||||
[{"source_name_normalized": "haferflocken"}],
|
||||
[],
|
||||
[
|
||||
{
|
||||
"source_name_raw": "Salz",
|
||||
"source_name_normalized": "salz",
|
||||
"count": 2,
|
||||
"first_date": date(2026, 9, 10),
|
||||
"last_date": date(2026, 9, 11),
|
||||
"sample_quantity_raw": "1 g",
|
||||
},
|
||||
{
|
||||
"source_name_raw": "Haferflocken",
|
||||
"source_name_normalized": "haferflocken",
|
||||
"count": 1,
|
||||
"first_date": date(2026, 9, 1),
|
||||
"last_date": date(2026, 9, 8),
|
||||
"sample_quantity_raw": "40 g",
|
||||
},
|
||||
],
|
||||
])
|
||||
rows = fetch_unmapped_rows(cur, "p")
|
||||
assert [r["source_name_normalized"] for r in rows] == ["salz"]
|
||||
assert rows[0]["last_date"] == "2026-09-11"
|
||||
assert rows[0]["kind"] == "recipe_ingredient"
|
||||
|
||||
|
||||
def test_unmapped_meta_includes_recent_ingredients():
|
||||
batches = [
|
||||
[],
|
||||
[],
|
||||
[{
|
||||
"source_name_raw": "Salz",
|
||||
"source_name_normalized": "salz",
|
||||
"count": 1,
|
||||
"first_date": date(2026, 9, 10),
|
||||
"last_date": date(2026, 9, 11),
|
||||
"sample_quantity_raw": "1 g",
|
||||
}],
|
||||
]
|
||||
payload = list_unmapped_payload(_FakeCur(batches), "p", since_days=0, meta=True)
|
||||
assert payload["total"] == 1
|
||||
assert payload["count"] == 1
|
||||
assert payload["items"][0]["source_name_normalized"] == "salz"
|
||||
|
||||
|
||||
def test_macros_differ_rounds():
|
||||
assert not macros_differ({"kcal": 1.04, "protein_g": 0, "fat_g": 0, "carbs_g": 0}, {"kcal": 1.0, "protein_g": 0, "fat_g": 0, "carbs_g": 0})
|
||||
assert macros_differ({"kcal": 10, "protein_g": 0, "fat_g": 0, "carbs_g": 0}, {"kcal": 11, "protein_g": 0, "fat_g": 0, "carbs_g": 0})
|
||||
|
|
|
|||
40
backend/tests/test_tandoor_client.py
Normal file
40
backend/tests/test_tandoor_client.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from data_layer.tandoor_client import TandoorError, normalize_base_url, public_settings, token_hint
|
||||
|
||||
|
||||
def test_normalize_https_strips_slash():
|
||||
assert normalize_base_url("https://tandoor.stommer.de/") == "https://tandoor.stommer.de"
|
||||
|
||||
|
||||
def test_normalize_rejects_empty_and_bare_host():
|
||||
try:
|
||||
normalize_base_url(" ")
|
||||
except TandoorError as e:
|
||||
assert "fehlt" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected TandoorError")
|
||||
try:
|
||||
normalize_base_url("tandoor.stommer.de")
|
||||
except TandoorError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected TandoorError")
|
||||
|
||||
|
||||
def test_normalize_rejects_public_http():
|
||||
try:
|
||||
normalize_base_url("http://tandoor.stommer.de")
|
||||
except TandoorError as e:
|
||||
assert "https" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected TandoorError")
|
||||
|
||||
|
||||
def test_token_hint_never_full():
|
||||
assert token_hint("abcdefghijklmnop") == "…mnop"
|
||||
assert "abcd" not in (token_hint("secret-token-value") or "")
|
||||
assert public_settings(None)["token_set"] is False
|
||||
pub = public_settings({"base_url": "https://tandoor.example", "api_token": "super-secret-token"})
|
||||
assert pub["token_set"] is True
|
||||
assert pub["token_hint"] == "…oken"
|
||||
assert "super" not in (pub["token_hint"] or "")
|
||||
assert "api_token" not in pub
|
||||
|
|
@ -9,7 +9,7 @@ Semantic Versioning: MAJOR.MINOR.PATCH
|
|||
|
||||
APP_VERSION = "0.9v"
|
||||
BUILD_DATE = "2026-09-12"
|
||||
DB_SCHEMA_VERSION = "20260912c" # 066 recipe ingredient units
|
||||
DB_SCHEMA_VERSION = "20260912e" # 068 nutrition unmapped/recipe indexes
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"auth": "1.2.0",
|
||||
|
|
@ -20,7 +20,8 @@ MODULE_VERSIONS = {
|
|||
"circumference": "1.0.1",
|
||||
"caliper": "1.0.1",
|
||||
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
|
||||
"nutrition": "1.3.2", # UI: Liste/Kombination; Tandoor-Gerichte später
|
||||
"nutrition": "1.3.3", # Unmapped ohne Kreuzjoin; Listen-Import Rebuild async
|
||||
"tandoor": "1.0.0", # Per-user URL + Token + Verbindungstest
|
||||
"bls": "1.0.5", # GET /bls/units
|
||||
"photos": "1.0.0",
|
||||
"insights": "1.3.0",
|
||||
|
|
@ -58,6 +59,7 @@ CHANGELOG = [
|
|||
"Rezept-Dialog: Zutaten aus Zuordnungen und Katalog suchen; Tandoor später",
|
||||
"Rezeptzutaten: Menge + Einheit (EL/TL/Prise/Stück); Umrechnung auf Gramm",
|
||||
"Zuordnen: Mitai = Liste/Kombination; gekochte Rezepte Tandoor + Ausbeute später",
|
||||
"Tandoor: persönlicher Zugang (URL + Token) in Einstellungen, Verbindungstest, kein Import",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -21,4 +21,6 @@ Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernende
|
|||
- Manueller Eintrag: beliebige `num_per_100g`-Stoffe (EPA/DHA/…) und Portionsumrechnung
|
||||
- Listen-Dialog mit Zutatensuche (Mappings + Katalog); Mitai = Roh-Kombination
|
||||
- Listenzutaten: Menge + Einheit, Umrechnung auf Gramm (EL/TL/Prise/Stück)
|
||||
- Folge: Tandoor-Gerichte + `cooked_yield_g` (Fertiggewicht) + Katalogeintrag pro 100 g — nicht Phase 1
|
||||
- Tandoor: persönlicher URL+Token in Einstellungen, Verbindungstest (kein Import)
|
||||
- Folge: Tandoor-Gerichte live lesen + BLS-Mapping + `cooked_yield_g` — kein Zutaten-Vollsync
|
||||
- Fix 2026-09-12: Offene Liste war leer, weil der 28-Tage-Filter Zutaten ohne `last_date` verwarf und die Unmapped-Query Listen×Tagebuch kreuzte (Minuten). Import wartete auf synchrone Tages-Rebuilds.
|
||||
|
|
|
|||
|
|
@ -13,10 +13,17 @@ export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
|
|||
setOpen(true)
|
||||
}
|
||||
|
||||
const startEdit = (r) => {
|
||||
setEditing(r)
|
||||
const startEdit = async (r) => {
|
||||
setError(null)
|
||||
try {
|
||||
const full = (r.ingredients && r.ingredients.length) || r.ingredient_count === 0
|
||||
? r
|
||||
: await api.getNutritionRecipe(r.id)
|
||||
setEditing(full)
|
||||
setOpen(true)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const save = async (body) => {
|
||||
|
|
@ -53,7 +60,7 @@ export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
|
|||
<div key={r.id} style={{ borderTop: '1px solid var(--border)', padding: '8px 0', marginTop: 8 }}>
|
||||
<div style={{ fontWeight: 600 }}>{r.name_raw}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||
{(r.ingredients || []).length} Zutaten · {r.portions || 1} Portionen
|
||||
{r.ingredient_count ?? (r.ingredients || []).length} Zutaten · {r.portions || 1} Portionen
|
||||
{r.source === 'manual' ? ' · selbst angelegt' : ''}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ function RecipePickModal({ recipes, sourceName, onPick, onClose }) {
|
|||
<button key={r.id} type="button" className="btn btn-secondary btn-full" style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }} onClick={() => onPick(r.id)}>
|
||||
<span>
|
||||
<strong style={{ display: 'block' }}>{r.name_raw}</strong>
|
||||
<span style={{ fontSize: 12, color: 'var(--text3)' }}>{(r.ingredients || []).length} Zutaten</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text3)' }}>{r.ingredient_count ?? (r.ingredients || []).length} Zutaten</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
|
@ -149,16 +149,16 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
|
|||
const load = async () => {
|
||||
const gen = ++loadGen.current
|
||||
try {
|
||||
const [u, m, r, counts] = await Promise.all([
|
||||
api.listUnmappedFoods(sinceDays),
|
||||
const [u, m, r] = await Promise.all([
|
||||
api.listUnmappedFoods(sinceDays, true),
|
||||
api.listMyFoodMappings(),
|
||||
api.listNutritionRecipes().catch(() => []),
|
||||
api.listUnmappedFoodCount(sinceDays).catch(() => null),
|
||||
api.listNutritionRecipes(true).catch(() => []),
|
||||
])
|
||||
if (gen !== loadGen.current) return
|
||||
askedSuggest.current = new Set()
|
||||
setUnmapped(Array.isArray(u) ? u : [])
|
||||
setTotalOpen(Number(counts?.total) || (Array.isArray(u) ? u.length : 0))
|
||||
const items = Array.isArray(u) ? u : (u?.items || [])
|
||||
setUnmapped(items)
|
||||
setTotalOpen(Number(u?.total) || items.length)
|
||||
setLearned(m)
|
||||
setRecipes(Array.isArray(r) ? r : [])
|
||||
} catch (e) {
|
||||
|
|
@ -340,7 +340,8 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
|
|||
const res = await api.importFddbLists(file)
|
||||
await load()
|
||||
onChanged?.()
|
||||
setNotice(`${res.recipes} Listen importiert, ${res.items_linked || 0} Tagebuchzeilen verknüpft.`)
|
||||
const pending = res.rebuild_pending ? ' Tageswerte werden im Hintergrund nachgerechnet.' : ''
|
||||
setNotice(`${res.recipes} Listen importiert, ${res.items_linked || 0} Tagebuchzeilen verknüpft.${pending}`)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
|
|
|
|||
140
frontend/src/components/TandoorSettings.jsx
Normal file
140
frontend/src/components/TandoorSettings.jsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
export default function TandoorSettings() {
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const [tokenSet, setTokenSet] = useState(false)
|
||||
const [tokenHint, setTokenHint] = useState(null)
|
||||
const [lastOk, setLastOk] = useState(null)
|
||||
const [lastError, setLastError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState(null)
|
||||
|
||||
const load = async () => {
|
||||
const s = await api.getTandoorSettings()
|
||||
setBaseUrl(s.base_url || '')
|
||||
setTokenSet(Boolean(s.token_set))
|
||||
setTokenHint(s.token_hint || null)
|
||||
setLastOk(s.last_ok_at || null)
|
||||
setLastError(s.last_error || null)
|
||||
setToken('')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load().catch((e) => setMsg({ type: 'err', text: e.message }))
|
||||
}, [])
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true)
|
||||
setMsg(null)
|
||||
try {
|
||||
const s = await api.putTandoorSettings({
|
||||
base_url: baseUrl,
|
||||
token: token.trim() || undefined,
|
||||
})
|
||||
setToken('')
|
||||
setTokenSet(Boolean(s.token_set))
|
||||
setTokenHint(s.token_hint || null)
|
||||
setLastError(s.last_error || null)
|
||||
setMsg({ type: 'ok', text: 'Gespeichert. Das Token bleibt nur auf dem Server.' })
|
||||
} catch (e) {
|
||||
setMsg({ type: 'err', text: e.message })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const test = async () => {
|
||||
setBusy(true)
|
||||
setMsg(null)
|
||||
try {
|
||||
const res = await api.testTandoorConnection({
|
||||
base_url: baseUrl || undefined,
|
||||
token: token.trim() || undefined,
|
||||
})
|
||||
const who = res.user ? ` als ${res.user}` : ''
|
||||
setMsg({ type: 'ok', text: `Verbindung ok${who}.` })
|
||||
setLastError(null)
|
||||
setLastOk(new Date().toISOString())
|
||||
} catch (e) {
|
||||
setMsg({ type: 'err', text: e.message })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const clear = async () => {
|
||||
if (!confirm('Tandoor-Zugang wirklich entfernen?')) return
|
||||
setBusy(true)
|
||||
setMsg(null)
|
||||
try {
|
||||
await api.putTandoorSettings({ base_url: '', clear_token: true })
|
||||
setBaseUrl('')
|
||||
setToken('')
|
||||
setTokenSet(false)
|
||||
setTokenHint(null)
|
||||
setLastOk(null)
|
||||
setLastError(null)
|
||||
setMsg({ type: 'ok', text: 'Zugang entfernt.' })
|
||||
} catch (e) {
|
||||
setMsg({ type: 'err', text: e.message })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Tandoor (Familienrezepte)</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 14, lineHeight: 1.6 }}>
|
||||
Persönlicher Zugang zu eurem Tandoor — nicht systemweit. Gerichte bleiben dort; Mitai rechnet später Nährwerte.
|
||||
Token unter Tandoor → Einstellungen → API-Tokens anlegen.
|
||||
</p>
|
||||
<div className="settings-page__field">
|
||||
<label className="settings-page__field-label" htmlFor="tandoor-url">Adresse</label>
|
||||
<input
|
||||
id="tandoor-url"
|
||||
className="form-input"
|
||||
type="url"
|
||||
placeholder="https://tandoor.stommer.de"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-page__field">
|
||||
<label className="settings-page__field-label" htmlFor="tandoor-token">API-Token</label>
|
||||
<input
|
||||
id="tandoor-token"
|
||||
className="form-input"
|
||||
type="password"
|
||||
placeholder={tokenSet ? `Gespeichert (${tokenHint || '…'}) — leer lassen zum Behalten` : 'Token einfügen'}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
{lastOk && (
|
||||
<p style={{ fontSize: 12, color: 'var(--accent-dark)', margin: '0 0 8px' }}>
|
||||
Zuletzt erfolgreich: {String(lastOk).slice(0, 16).replace('T', ' ')}
|
||||
</p>
|
||||
)}
|
||||
{lastError && !msg && (
|
||||
<p style={{ fontSize: 12, color: 'var(--danger)', margin: '0 0 8px' }}>Letzter Fehler: {lastError}</p>
|
||||
)}
|
||||
{msg && (
|
||||
<div style={{ fontSize: 13, color: msg.type === 'err' ? 'var(--danger)' : 'var(--accent-dark)', marginBottom: 10 }}>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={save}>Speichern</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={test}>Verbindung testen</button>
|
||||
{tokenSet && (
|
||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={clear}>Zugang entfernen</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { useAuth } from '../context/AuthContext'
|
|||
import { Avatar } from './ProfileSelect'
|
||||
import { api } from '../utils/api'
|
||||
import FeatureUsageOverview from '../components/FeatureUsageOverview'
|
||||
import TandoorSettings from '../components/TandoorSettings'
|
||||
import UsageBadge from '../components/UsageBadge'
|
||||
|
||||
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
|
||||
|
|
@ -428,6 +429,8 @@ export default function SettingsPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<TandoorSettings />
|
||||
|
||||
<div className="card section-gap">
|
||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<LayoutDashboard size={15} color="var(--accent)" /> Startseite (Übersicht)
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@ export const api = {
|
|||
deleteProfile: (id) => req(`/profiles/${id}`, {method:'DELETE'}),
|
||||
getProfile: () => req('/profile'),
|
||||
updateActiveProfile:(d)=> req('/profile', jput(d)),
|
||||
getTandoorSettings: () => req('/tandoor/settings'),
|
||||
putTandoorSettings: (d) => req('/tandoor/settings', jput(d)),
|
||||
testTandoorConnection: (d) => req('/tandoor/test', json(d || {})),
|
||||
|
||||
// App-Bereich: konfigurierbares Dashboard (Layout JSON) + Widget-Katalog
|
||||
getAppWidgetsCatalog: () => req('/app/widgets/catalog'),
|
||||
|
|
@ -240,9 +243,10 @@ export const api = {
|
|||
const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d
|
||||
},
|
||||
listNutritionItems: (date) => req(date ? `/nutrition/items?date=${date}` : '/nutrition/items'),
|
||||
listUnmappedFoods: (sinceDays=0) => req(`/nutrition/unmapped?since_days=${sinceDays || 0}`),
|
||||
listUnmappedFoods: (sinceDays=0, meta=false) => req(`/nutrition/unmapped?since_days=${sinceDays || 0}${meta ? '&meta=true' : ''}`),
|
||||
listUnmappedFoodCount: (sinceDays=28) => req(`/nutrition/unmapped?count_only=true&since_days=${sinceDays || 0}`),
|
||||
listNutritionRecipes: () => req('/nutrition/recipes'),
|
||||
listNutritionRecipes: (brief=false) => req(`/nutrition/recipes${brief ? '?brief=true' : ''}`),
|
||||
getNutritionRecipe: (id) => req(`/nutrition/recipes/${id}`),
|
||||
createNutritionRecipe: (d) => req('/nutrition/recipes', json(d)),
|
||||
updateNutritionRecipe: (id, d) => req(`/nutrition/recipes/${id}`, jput(d)),
|
||||
deleteNutritionRecipe: (id) => req(`/nutrition/recipes/${id}`, {method:'DELETE'}),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user