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)
|
## 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)
|
## 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).
|
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
|
# BLS Food Reference – technische Spec
|
||||||
|
|
||||||
**Stand:** 2026-09-12 · Migration **062** + **063**
|
**Stand:** 2026-09-12 · Migration **062**–**068**
|
||||||
|
|
||||||
## Tabellen
|
## 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`
|
- `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
|
- `PUT /api/admin/food-mappings/{id}` — Ziel-Lebensmittel einer bestehenden Zuordnung wechseln
|
||||||
- Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Listenzutaten ohne Mapping
|
- 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).
|
- 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)
|
- 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**)
|
- `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).
|
- `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
|
**Problem:** Versuch, `.claude/task/` Files zu committen
|
||||||
**Lösung:** Nur `backend/` Code committen, `.claude/` ist local docs
|
**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:**
|
**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.
|
- **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.
|
- **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.
|
- **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“.
|
- **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.
|
- **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.
|
- **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
|
- **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.
|
- **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
|
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]:
|
def sort_unmapped_rows(rows: list[dict], since_days: int = 0) -> list[dict]:
|
||||||
rows = list(rows)
|
rows = list(rows)
|
||||||
if int(since_days or 0) > 0:
|
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:
|
if not inorm:
|
||||||
continue
|
continue
|
||||||
raw_name = ing.get("source_name_raw") or inorm
|
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(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO food_recipe_ingredients
|
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
|
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(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, name_raw, name_normalized, portions, description, source
|
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:
|
if not recipes:
|
||||||
return []
|
return []
|
||||||
ids = [str(r["id"]) for r in recipes]
|
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(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT recipe_id, source_name_raw, source_name_normalized,
|
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[])
|
WHERE recipe_id = ANY(%s::uuid[])
|
||||||
ORDER BY sort_order, source_name_raw
|
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():
|
for row in cur.fetchall():
|
||||||
by_r.setdefault(str(row["recipe_id"]), []).append(dict(row))
|
by_r.setdefault(str(row["recipe_id"]), []).append(dict(row))
|
||||||
for rec in recipes:
|
return by_r
|
||||||
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:
|
def get_recipe(cur, profile_id: str, recipe_id: str) -> dict[str, Any] | None:
|
||||||
for rec in list_recipes(cur, profile_id):
|
cur.execute(
|
||||||
if rec["id"] == str(recipe_id):
|
"""
|
||||||
return rec
|
SELECT id, name_raw, name_normalized, portions, description, source
|
||||||
return None
|
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]:
|
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."""
|
"""Nutrition diary items, three macro sums, import policy, attribute resolve."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from data_layer.food_mapping import (
|
from data_layer.food_mapping import (
|
||||||
get_food_mapping_with_cursor,
|
get_food_mapping_with_cursor,
|
||||||
normalize_food_name,
|
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)
|
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:
|
def rebuild_daily_nutrients(cur, profile_id: str, day: date | str) -> None:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"DELETE FROM nutrition_daily_nutrients WHERE profile_id = %s AND date = %s",
|
"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 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 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 bls, admin_bls, admin_food_mappings # BLS catalog + FDDB mapping
|
||||||
|
from routers import tandoor # per-user Tandoor connection
|
||||||
|
|
||||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
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(bls.router) # /api/bls/*
|
||||||
app.include_router(admin_bls.router) # /api/admin/bls/*
|
app.include_router(admin_bls.router) # /api/admin/bls/*
|
||||||
app.include_router(admin_food_mappings.router) # /api/admin/food-mappings
|
app.include_router(admin_food_mappings.router) # /api/admin/food-mappings
|
||||||
|
app.include_router(tandoor.router) # /api/tandoor/*
|
||||||
|
|
||||||
# ── Health Check ──────────────────────────────────────────────────────────────
|
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||||
@app.get("/")
|
@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(
|
def list_unmapped_foods(
|
||||||
since_days: int = 0,
|
since_days: int = 0,
|
||||||
count_only: bool = False,
|
count_only: bool = False,
|
||||||
|
meta: bool = False,
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
session: dict = Depends(require_auth),
|
session: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
from data_layer.food_mapping import (
|
from data_layer.food_mapping import list_unmapped_payload
|
||||||
as_iso_date,
|
|
||||||
filter_unmapped_since,
|
|
||||||
merge_unmapped_rows,
|
|
||||||
normalize_food_name,
|
|
||||||
sort_unmapped_rows,
|
|
||||||
)
|
|
||||||
|
|
||||||
pid = x_profile_id or session["profile_id"]
|
pid = x_profile_id or session["profile_id"]
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
return list_unmapped_payload(get_cursor(conn), pid, since_days, count_only, meta)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recipes")
|
@router.get("/recipes")
|
||||||
def list_food_recipes(
|
def list_food_recipes(
|
||||||
|
brief: bool = False,
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
session: dict = Depends(require_auth),
|
session: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
from data_layer.food_recipes import list_recipes
|
from data_layer.food_recipes import list_recipes
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
with get_db() as conn:
|
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")
|
@router.post("/recipes")
|
||||||
|
|
@ -530,10 +476,10 @@ async def import_fddb_lists(
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
stats = upsert_recipes(cur, pid, recipes)
|
stats = upsert_recipes(cur, pid, recipes)
|
||||||
from data_layer.nutrition_items import rebuild_daily_nutrients
|
dates = stats.pop("dates_linked", []) or []
|
||||||
for d in stats.pop("dates_linked", []) or []:
|
from data_layer.nutrition_items import schedule_daily_nutrient_rebuild
|
||||||
rebuild_daily_nutrients(cur, pid, d)
|
schedule_daily_nutrient_rebuild(pid, dates, "FDDB-Listenimport")
|
||||||
return {"ok": True, "recipes": len(recipes), **stats}
|
return {"ok": True, "recipes": len(recipes), "rebuild_pending": len(dates), **stats}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/recipes/{recipe_id}/apply")
|
@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 csv_parser.executor import guess_nutrition_item_fields
|
||||||
from data_layer.food_mapping import (
|
from data_layer.food_mapping import (
|
||||||
|
fetch_unmapped_rows,
|
||||||
filter_unmapped_since,
|
filter_unmapped_since,
|
||||||
list_quantity_units,
|
list_quantity_units,
|
||||||
|
list_unmapped_payload,
|
||||||
merge_unmapped_rows,
|
merge_unmapped_rows,
|
||||||
normalize_food_name,
|
normalize_food_name,
|
||||||
parse_quantity,
|
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
|
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():
|
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 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})
|
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"
|
APP_VERSION = "0.9v"
|
||||||
BUILD_DATE = "2026-09-12"
|
BUILD_DATE = "2026-09-12"
|
||||||
DB_SCHEMA_VERSION = "20260912c" # 066 recipe ingredient units
|
DB_SCHEMA_VERSION = "20260912e" # 068 nutrition unmapped/recipe indexes
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"auth": "1.2.0",
|
"auth": "1.2.0",
|
||||||
|
|
@ -20,7 +20,8 @@ MODULE_VERSIONS = {
|
||||||
"circumference": "1.0.1",
|
"circumference": "1.0.1",
|
||||||
"caliper": "1.0.1",
|
"caliper": "1.0.1",
|
||||||
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
|
"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
|
"bls": "1.0.5", # GET /bls/units
|
||||||
"photos": "1.0.0",
|
"photos": "1.0.0",
|
||||||
"insights": "1.3.0",
|
"insights": "1.3.0",
|
||||||
|
|
@ -58,6 +59,7 @@ CHANGELOG = [
|
||||||
"Rezept-Dialog: Zutaten aus Zuordnungen und Katalog suchen; Tandoor später",
|
"Rezept-Dialog: Zutaten aus Zuordnungen und Katalog suchen; Tandoor später",
|
||||||
"Rezeptzutaten: Menge + Einheit (EL/TL/Prise/Stück); Umrechnung auf Gramm",
|
"Rezeptzutaten: Menge + Einheit (EL/TL/Prise/Stück); Umrechnung auf Gramm",
|
||||||
"Zuordnen: Mitai = Liste/Kombination; gekochte Rezepte Tandoor + Ausbeute später",
|
"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
|
- Manueller Eintrag: beliebige `num_per_100g`-Stoffe (EPA/DHA/…) und Portionsumrechnung
|
||||||
- Listen-Dialog mit Zutatensuche (Mappings + Katalog); Mitai = Roh-Kombination
|
- Listen-Dialog mit Zutatensuche (Mappings + Katalog); Mitai = Roh-Kombination
|
||||||
- Listenzutaten: Menge + Einheit, Umrechnung auf Gramm (EL/TL/Prise/Stück)
|
- 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)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const startEdit = (r) => {
|
const startEdit = async (r) => {
|
||||||
setEditing(r)
|
|
||||||
setError(null)
|
setError(null)
|
||||||
setOpen(true)
|
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) => {
|
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 key={r.id} style={{ borderTop: '1px solid var(--border)', padding: '8px 0', marginTop: 8 }}>
|
||||||
<div style={{ fontWeight: 600 }}>{r.name_raw}</div>
|
<div style={{ fontWeight: 600 }}>{r.name_raw}</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
<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' : ''}
|
{r.source === 'manual' ? ' · selbst angelegt' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
|
<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)}>
|
<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>
|
<span>
|
||||||
<strong style={{ display: 'block' }}>{r.name_raw}</strong>
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
@ -149,16 +149,16 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const gen = ++loadGen.current
|
const gen = ++loadGen.current
|
||||||
try {
|
try {
|
||||||
const [u, m, r, counts] = await Promise.all([
|
const [u, m, r] = await Promise.all([
|
||||||
api.listUnmappedFoods(sinceDays),
|
api.listUnmappedFoods(sinceDays, true),
|
||||||
api.listMyFoodMappings(),
|
api.listMyFoodMappings(),
|
||||||
api.listNutritionRecipes().catch(() => []),
|
api.listNutritionRecipes(true).catch(() => []),
|
||||||
api.listUnmappedFoodCount(sinceDays).catch(() => null),
|
|
||||||
])
|
])
|
||||||
if (gen !== loadGen.current) return
|
if (gen !== loadGen.current) return
|
||||||
askedSuggest.current = new Set()
|
askedSuggest.current = new Set()
|
||||||
setUnmapped(Array.isArray(u) ? u : [])
|
const items = Array.isArray(u) ? u : (u?.items || [])
|
||||||
setTotalOpen(Number(counts?.total) || (Array.isArray(u) ? u.length : 0))
|
setUnmapped(items)
|
||||||
|
setTotalOpen(Number(u?.total) || items.length)
|
||||||
setLearned(m)
|
setLearned(m)
|
||||||
setRecipes(Array.isArray(r) ? r : [])
|
setRecipes(Array.isArray(r) ? r : [])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -340,7 +340,8 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
|
||||||
const res = await api.importFddbLists(file)
|
const res = await api.importFddbLists(file)
|
||||||
await load()
|
await load()
|
||||||
onChanged?.()
|
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) {
|
} catch (e) {
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
} finally {
|
} 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 { Avatar } from './ProfileSelect'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import FeatureUsageOverview from '../components/FeatureUsageOverview'
|
import FeatureUsageOverview from '../components/FeatureUsageOverview'
|
||||||
|
import TandoorSettings from '../components/TandoorSettings'
|
||||||
import UsageBadge from '../components/UsageBadge'
|
import UsageBadge from '../components/UsageBadge'
|
||||||
|
|
||||||
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
|
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
|
||||||
|
|
@ -428,6 +429,8 @@ export default function SettingsPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TandoorSettings />
|
||||||
|
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<LayoutDashboard size={15} color="var(--accent)" /> Startseite (Übersicht)
|
<LayoutDashboard size={15} color="var(--accent)" /> Startseite (Übersicht)
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,9 @@ export const api = {
|
||||||
deleteProfile: (id) => req(`/profiles/${id}`, {method:'DELETE'}),
|
deleteProfile: (id) => req(`/profiles/${id}`, {method:'DELETE'}),
|
||||||
getProfile: () => req('/profile'),
|
getProfile: () => req('/profile'),
|
||||||
updateActiveProfile:(d)=> req('/profile', jput(d)),
|
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
|
// App-Bereich: konfigurierbares Dashboard (Layout JSON) + Widget-Katalog
|
||||||
getAppWidgetsCatalog: () => req('/app/widgets/catalog'),
|
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
|
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'),
|
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}`),
|
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)),
|
createNutritionRecipe: (d) => req('/nutrition/recipes', json(d)),
|
||||||
updateNutritionRecipe: (id, d) => req(`/nutrition/recipes/${id}`, jput(d)),
|
updateNutritionRecipe: (id, d) => req(`/nutrition/recipes/${id}`, jput(d)),
|
||||||
deleteNutritionRecipe: (id) => req(`/nutrition/recipes/${id}`, {method:'DELETE'}),
|
deleteNutritionRecipe: (id) => req(`/nutrition/recipes/${id}`, {method:'DELETE'}),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user