Compare commits
7 Commits
037e8bab47
...
c1f45d3ba1
| Author | SHA1 | Date | |
|---|---|---|---|
| c1f45d3ba1 | |||
| 44e3e216ab | |||
| 94ddf84262 | |||
| e452d9a8fe | |||
| 9a0767c8ac | |||
| 8b1e1da5d8 | |||
| d35087ad84 |
|
|
@ -1,6 +1,6 @@
|
|||
# BLS-Lebensmittelreferenz und FDDB-Mapping
|
||||
|
||||
**Stand:** 2026-09-12 · **Status:** Phase 1 (Umsetzung)
|
||||
**Stand:** 2026-09-14 · **Status:** Phase 1 (Umsetzung)
|
||||
|
||||
## WAS
|
||||
|
||||
|
|
@ -37,14 +37,18 @@ Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer a
|
|||
- BLS-Code (`bls_code`, Stoff-`attr_key`) bleibt die stabile Identität bei Reimports.
|
||||
- BLS 4.0 (~7140 Lebensmittel) ist frei nutzbar (MRI / blsdb.de); Dateien nicht im Git.
|
||||
- Mapping ohne KI: Normalisierung + exakter Lookup (User vor Global) + Bestätigung neuer Namen.
|
||||
- Gelernte Zuordnungen bleiben dauerhaft, sind aber änder- und löschbar.
|
||||
- Öl/Supplement mit Etikett (EPA/DHA, Vitamine) ist **ein Katalogeintrag**, keine Zutatenliste. Listen sind nur Rohmischungen aus Lebensmitteln — eine Liste kann keine andere Liste enthalten. Quarkspeise = Liste, Omega-3-Öl = Lebensmittel darin.
|
||||
- Gelernte Zuordnungen bleiben dauerhaft, sind aber änder- und löschbar. Nutzer-Mapping vor globalem Mapping. Globales Mapping nur auf BLS oder zentrale Admin-Foods.
|
||||
- Sichtbarkeit: persönlich (nur Owner) / zentral (alle) / BLS (alle). Vorschläge und Suche filtern danach; bei gleichem Namen gewinnt persönlich vor zentral vor BLS.
|
||||
- Ungemappte / Fertiggerichte: FDDB-Makros, keine erfundenen Mikros.
|
||||
- Fasten und „unvollständig“ sind explizite Marken, kein Auto-Schluss aus fehlendem Import.
|
||||
- Import-Policy (Profil): nachfragen / Katalog überschreiben / FDDB überschreiben / Makros behalten.
|
||||
|
||||
## Manuelle Lebensmittel / Supplemente
|
||||
|
||||
Eigene Einträge (z. B. Norsan Omega-3 + EPA) speichern **dieselben Stoffwerte** wie BLS-Lebensmittel (`food_attribute_values`, immer pro 100 g). Beim Anlegen: Makros plus Suche nach weiteren Stoffen (EPA, DHA, Omega-3). Etikett „pro 8 ml“ → Portionsgröße in g angeben, Umrechnung auf 100 g erfolgt serverseitig. Einheit des Stoffs beachten (oft **g**, Etikett in mg: 1100 mg = 1,1 g). Fehlt ein Stoff im Katalog: Admin → Stoffe & Attribute.
|
||||
Eigene Einträge des Nutzers sind **persönlich** (`manual_user`). Der Admin legt **zentrale** Einträge für alle an (`manual_admin`). Beide speichern **dieselben Stoffwerte** wie BLS-Lebensmittel (`food_attribute_values`, immer pro 100 g). Beim Anlegen: Makros plus Suche nach weiteren Stoffen (EPA, DHA, Omega-3). Etikett „pro 8 ml“ → Portionsgröße in g angeben, Umrechnung auf 100 g erfolgt serverseitig. Einheit des Stoffs beachten (oft **g**, Etikett in mg: 1100 mg = 1,1 g). Fehlt ein Stoff im Katalog: Nutzer kann ihn selbst anlegen oder Admin → Stoffe & Attribute (Bezeichnung änderbar, Schlüssel fest). In die Makro-Haupttabelle (`nutrition_log`) fließen nur ENERCC→kcal, PROT625→protein_g, FAT→fat_g, CHO→carbs_g (Wert je 100 g × gegessene g / 100). Andere Stoffe gehen nach `nutrition_daily_nutrients`.
|
||||
|
||||
**Nachträglich ändern:** Unter Ernährung → Tab **Lebensmittel** (persönliche Liste + zentrale Admin-Einträge, Suche). Persönliche Einträge legt und ändert der Owner. Zentrale Einträge liest jeder; **ändert nur ein Admin** (gleiche Liste, Zeile oder Bearbeiten). Beim Zuordnen gilt die Reihenfolge persönlich → zentral → BLS. Globale Namens-Mappings (Admin) gelten für alle, dürfen aber nicht auf ein privates Nutzer-Lebensmittel zeigen. Offizielle BLS-Einträge bleiben fest. Beim Speichern gelten die Felder als Werte je 100 g, außer die Portionsgröße wird bewusst auf die Etikett-Portion gesetzt. Betroffene Tagebuch-Tage werden im Hintergrund neu berechnet. Admin → Lebensmittelkatalog → Reiter **Manuell** zeigt **Zentral** und **Persönlich** getrennt; beide sind dort änderbar.
|
||||
|
||||
Weitere Quellen (USDA, Schweizer Nährwertdatenbank) kommen später als zusätzliche Katalogherkunft, nicht als zweite Wertetabelle.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# BLS Food Reference – technische Spec
|
||||
|
||||
**Stand:** 2026-09-12 · Migration **062**–**068**
|
||||
**Stand:** 2026-09-14 · Migration **062**–**069**
|
||||
|
||||
## Tabellen
|
||||
|
||||
|
|
@ -28,15 +28,18 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy /
|
|||
|
||||
## Router
|
||||
|
||||
- `/api/bls/*` — Suche, Attribute-Suche, eigene Foods (`attributes` + `serving_g`), eigene Mappings
|
||||
- `/api/bls/*` — Suche, Attribute-Suche, eigene Foods (`attributes` + `serving_g`), eigene Mappings; `POST /bls/attributes` legt Extension-Stoff an (Name + Einheit), `GET /bls/attributes?keys=EPA,DHA`
|
||||
- `GET /bls/foods/mine` — eigene `manual_user`-Einträge; `GET /bls/foods/shared` — zentrale `manual_admin`; `GET /bls/foods/{id}` — Editor-Payload inkl. `visibility` und `editable` (Owner persönlich; Admin auch zentral); `PUT /bls/foods/manual/{id}` — nur Owner. Zentrale Updates: `PUT /admin/bls/foods/manual/{id}` (Admin). Suche `?scope=global` blendet persönliche Foods aus (Admin-Mappings).
|
||||
- Sichtbarkeit: `data_layer/food_visibility.py`. Lookup-Mapping: User-Zeile nur wenn Food sichtbar; globale Mapping-Zeile nur `owner_profile_id IS NULL`. `upsert_food_mapping` erzwingt dasselbe. Vorschläge sortieren persönlich → zentral → BLS.
|
||||
- `GET /admin/bls/foods?kind=manual` — `manual_user`/`manual_admin`. `POST /admin/bls/foods/manual` — zentrale Foods. `PUT /admin/bls/foods/manual/{id}` — zentral **und** persönlich (`attributes` + `serving_g`). `GET /admin/bls/foods/{id}?editor=true` — Editor-Payload. Migration **069**: Owner-Check (persönlich hat Owner, BLS/Admin nicht).
|
||||
- Migration **065** — Extension-Keys EPA/DHA/DPA/ALA/OMEGA3/OMEGA6 falls BLS sie nicht unter diesem Key hat
|
||||
- `/api/admin/bls/*` — Import, Katalog, Attribute
|
||||
- `/api/admin/bls/*` — Import, Katalog, Attribute. `GET /admin/bls/attributes` liefert `group_key`/`group_label` (BLS-Gruppen, sortiert). `PUT /admin/bls/attributes/{id}` ändert Name/Einheit/Gruppe (nicht `attr_key`). `POST /admin/bls/attributes/fill-labels` füllt leere Namen und fehlende Gruppen aus BLS-4.0-Glossar (`bls/attribute_labels.py`). Lebensmittel-Import schreibt fehlende Namen aus Spaltenköpfen. Makro-Rollup: `catalog_macros_for_item` liest nur ENERCC/PROT625/FAT/CHO → `nutrition_log`; `rebuild_daily_nutrients` summiert alle `num_per_100g` nach `nutrition_daily_nutrients`.
|
||||
- `/api/admin/food-mappings` — Admin-CRUD
|
||||
- `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve
|
||||
- `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).
|
||||
- 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). Eigene Foods: Tab **Lebensmittel** (`MyFoodsPanel` + `FoodEditDialog`). 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}`; `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)`.
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ frontend/src/
|
|||
- **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.
|
||||
- **Manuelle Foods:** Persönlich (`manual_user` + Owner, nur dieser Nutzer) oder zentral (`manual_admin`, alle Nutzer). Stoffe über EAV. Nutzer: Tab Ernährung → **Lebensmittel** (persönlich ändern; zentral nur Admin). Admin: Katalog → Manuell — Listen **Zentral** und **Persönlich**, beide änderbar. Stoffe & Attribute: Bezeichnung editierbar, Schlüssel fest; Makros nur ENERCC/PROT625/FAT/CHO → `nutrition_log`. Mapping: Nutzer-Mapping darf sichtbare Foods; globales Mapping nur BLS/zentral. Offizielle BLS-Zeilen nicht editierbar. Öl/Supplement = ein Lebensmittel, keine Liste.
|
||||
- **Listen / Kombinationen (Mitai):** Tab **Listen** (anlegen, bearbeiten, FDDB-Import). Im Listendialog Zutaten direkt BLS-zuordnen (Status offen/zugeordnet); Suche zuerst Listen-Namen und eigene Foods, dann BLS. **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.
|
||||
|
|
|
|||
321
backend/bls/attribute_labels.py
Normal file
321
backend/bls/attribute_labels.py
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
"""Official BLS 4.0 component labels (MRI documentation) and placeholder repair.
|
||||
|
||||
``attr_key`` stays the identity. These names only fill empty or key-equal ``name_de``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Keys that feed nutrition_log kcal / protein_g / fat_g / carbs_g
|
||||
MACRO_KEYS = ("ENERCC", "PROT625", "FAT", "CHO")
|
||||
|
||||
GROUP_META = (
|
||||
("energy", "Energie"),
|
||||
("macro", "Makronährstoffe"),
|
||||
("carbs", "Kohlenhydrate"),
|
||||
("fiber", "Ballaststoffe"),
|
||||
("fatty_acids", "Fettsäuren"),
|
||||
("amino_acids", "Aminosäuren"),
|
||||
("vitamins", "Vitamine"),
|
||||
("minerals", "Mineralstoffe"),
|
||||
("organic_acids", "Organische Säuren"),
|
||||
("polyols", "Zuckeralkohole"),
|
||||
("other", "Sonstige"),
|
||||
("extension", "Eigene Erweiterungen"),
|
||||
("unknown", "Ohne Gruppe"),
|
||||
)
|
||||
GROUP_LABELS = {key: label for key, label in GROUP_META}
|
||||
GROUP_RANK = {key: i for i, (key, _) in enumerate(GROUP_META)}
|
||||
|
||||
# Code → (name_de, unit, category) from BLS 4.0 Referenzhandbuch (Jan 2026)
|
||||
_OFFICIAL: dict[str, tuple[str, str, str]] = {
|
||||
"ENERCJ": ("Energie (Kilojoule)", "kJ", "energy"),
|
||||
"ENERCC": ("Energie (Kilokalorien)", "kcal", "energy"),
|
||||
"WATER": ("Wasser", "g", "macro"),
|
||||
"PROT625": ("Protein (N×6,25)", "g", "macro"),
|
||||
"FAT": ("Fett", "g", "macro"),
|
||||
"CHO": ("Kohlenhydrate, verfügbar", "g", "macro"),
|
||||
"FIBT": ("Ballaststoffe, gesamt", "g", "fiber"),
|
||||
"ALC": ("Alkohol (Ethanol)", "g", "macro"),
|
||||
"OA": ("Organische Säuren, gesamt", "g", "organic_acids"),
|
||||
"ASH": ("Rohasche", "g", "macro"),
|
||||
"POLYL": ("Zuckeralkohole, gesamt", "g", "polyols"),
|
||||
"GLUS": ("Glucose", "g", "carbs"),
|
||||
"FRUS": ("Fructose", "g", "carbs"),
|
||||
"GALS": ("Galactose", "g", "carbs"),
|
||||
"MNSAC": ("Monosaccharide, gesamt", "g", "carbs"),
|
||||
"SUCS": ("Saccharose", "g", "carbs"),
|
||||
"MALS": ("Maltose", "g", "carbs"),
|
||||
"LACS": ("Lactose", "g", "carbs"),
|
||||
"DISAC": ("Disaccharide, gesamt", "g", "carbs"),
|
||||
"SUGAR": ("Zucker, gesamt", "g", "carbs"),
|
||||
"OLSAC": ("Oligosaccharide, verfügbar", "g", "carbs"),
|
||||
"STARCH": ("Stärke", "g", "carbs"),
|
||||
"FIBLMW": ("Ballaststoffe, niedermolekular", "g", "fiber"),
|
||||
"FIBHMW": ("Ballaststoffe, hochmolekular", "g", "fiber"),
|
||||
"FIBINS": ("Ballaststoffe, wasserunlöslich", "g", "fiber"),
|
||||
"FIBSOL": ("Ballaststoffe, wasserlöslich", "g", "fiber"),
|
||||
"FIBHMWS": ("Ballaststoffe, hochmolekular, wasserlöslich", "g", "fiber"),
|
||||
"FIBHMWI": ("Ballaststoffe, hochmolekular, wasserunlöslich", "g", "fiber"),
|
||||
"FASAT": ("Fettsäuren, gesättigt, gesamt", "g", "fatty_acids"),
|
||||
"F4:0": ("Buttersäure (C4:0)", "g", "fatty_acids"),
|
||||
"F6:0": ("Capronsäure (C6:0)", "g", "fatty_acids"),
|
||||
"F8:0": ("Caprylsäure (C8:0)", "g", "fatty_acids"),
|
||||
"F10:0": ("Caprinsäure (C10:0)", "g", "fatty_acids"),
|
||||
"F12:0": ("Laurinsäure (C12:0)", "g", "fatty_acids"),
|
||||
"F14:0": ("Myristinsäure (C14:0)", "g", "fatty_acids"),
|
||||
"F15:0": ("Pentadecylsäure (C15:0)", "g", "fatty_acids"),
|
||||
"F16:0": ("Palmitinsäure (C16:0)", "g", "fatty_acids"),
|
||||
"F17:0": ("Margarinsäure (C17:0)", "g", "fatty_acids"),
|
||||
"F18:0": ("Stearinsäure (C18:0)", "g", "fatty_acids"),
|
||||
"F20:0": ("Arachinsäure (C20:0)", "g", "fatty_acids"),
|
||||
"F22:0": ("Behensäure (C22:0)", "g", "fatty_acids"),
|
||||
"F24:0": ("Lignocerinsäure (C24:0)", "g", "fatty_acids"),
|
||||
"FAMS": ("Fettsäuren, einfach ungesättigt, gesamt", "g", "fatty_acids"),
|
||||
"F14:1CN5": ("Myristoleinsäure (C14:1 n-5 cis)", "g", "fatty_acids"),
|
||||
"F16:1CN7": ("Palmitoleinsäure (C16:1 n-7 cis)", "g", "fatty_acids"),
|
||||
"F18:1CN7": ("Vaccensäure (C18:1 n-7 cis)", "g", "fatty_acids"),
|
||||
"F18:1CN9": ("Ölsäure (C18:1 n-9 cis)", "g", "fatty_acids"),
|
||||
"F20:1CN9": ("Gondosäure (C20:1 n-9 cis)", "g", "fatty_acids"),
|
||||
"F22:1CN9": ("Erucasäure (C22:1 n-9 cis)", "g", "fatty_acids"),
|
||||
"FAPU": ("Fettsäuren, mehrfach ungesättigt, gesamt", "g", "fatty_acids"),
|
||||
"FAPUN3": ("Omega-3-Fettsäuren, gesamt", "g", "fatty_acids"),
|
||||
"FAPUN6": ("Omega-6-Fettsäuren, gesamt", "g", "fatty_acids"),
|
||||
"F18:2CN6": ("Linolsäure (C18:2 n-6 cis)", "g", "fatty_acids"),
|
||||
"F18:2C9T11": ("Konjugierte Linolsäure (C18:2 cis-9, trans-11)", "g", "fatty_acids"),
|
||||
"F18:3CN3": ("Alpha-Linolensäure (C18:3 n-3)", "g", "fatty_acids"),
|
||||
"F18:3CN6": ("Gamma-Linolensäure (C18:3 n-6)", "g", "fatty_acids"),
|
||||
"F18:4CN3": ("Stearidonsäure (C18:4 n-3)", "g", "fatty_acids"),
|
||||
"F20:2CN6": ("Eicosadiensäure (C20:2 n-6)", "g", "fatty_acids"),
|
||||
"F20:3CN6": ("Dihomo-Gamma-Linolensäure (C20:3 n-6)", "g", "fatty_acids"),
|
||||
"F20:4CN6": ("Arachidonsäure (C20:4 n-6)", "g", "fatty_acids"),
|
||||
"F20:5CN3": ("Eicosapentaensäure EPA (C20:5 n-3)", "g", "fatty_acids"),
|
||||
"F22:5CN3": ("Docosapentaensäure DPA (C22:5 n-3)", "g", "fatty_acids"),
|
||||
"F22:6CN3": ("Docosahexaensäure DHA (C22:6 n-3)", "g", "fatty_acids"),
|
||||
"FAX": ("Fettsäuren, sonstige", "g", "fatty_acids"),
|
||||
"AAE9": ("Aminosäuren, unentbehrlich, gesamt", "g", "amino_acids"),
|
||||
"ILE": ("Isoleucin", "g", "amino_acids"),
|
||||
"LEU": ("Leucin", "g", "amino_acids"),
|
||||
"LYS": ("Lysin", "g", "amino_acids"),
|
||||
"MET": ("Methionin", "g", "amino_acids"),
|
||||
"PHE": ("Phenylalanin", "g", "amino_acids"),
|
||||
"THR": ("Threonin", "g", "amino_acids"),
|
||||
"TRP": ("Tryptophan", "g", "amino_acids"),
|
||||
"VAL": ("Valin", "g", "amino_acids"),
|
||||
"HIS": ("Histidin", "g", "amino_acids"),
|
||||
"ALA": ("Alanin", "g", "amino_acids"),
|
||||
"ARG": ("Arginin", "g", "amino_acids"),
|
||||
"ASP": ("Asparaginsäure inkl. Asparagin", "g", "amino_acids"),
|
||||
"CYS": ("Cystein", "g", "amino_acids"),
|
||||
"GLU": ("Glutaminsäure inkl. Glutamin", "g", "amino_acids"),
|
||||
"GLY": ("Glycin", "g", "amino_acids"),
|
||||
"PRO": ("Prolin", "g", "amino_acids"),
|
||||
"SER": ("Serin", "g", "amino_acids"),
|
||||
"TYR": ("Tyrosin", "g", "amino_acids"),
|
||||
"VITA": ("Vitamin A, Retinol-Äquivalent (RE)", "µg", "vitamins"),
|
||||
"VITAA": ("Vitamin A, Retinol-Aktivitätsäquivalent (RAE)", "µg", "vitamins"),
|
||||
"RETOL": ("Retinol", "µg", "vitamins"),
|
||||
"CARTB": ("Beta-Carotin", "µg", "vitamins"),
|
||||
"CAROTPAXB": ("Carotinoide außer Beta-Carotin", "µg", "vitamins"),
|
||||
"VITD": ("Vitamin D", "µg", "vitamins"),
|
||||
"ERGCAL": ("Vitamin D2 (Ergocalciferol)", "µg", "vitamins"),
|
||||
"CHOCAL": ("Vitamin D3 (Cholecalciferol)", "µg", "vitamins"),
|
||||
"VITE": ("Vitamin E (Alpha-Tocopherol)", "mg", "vitamins"),
|
||||
"TOCPHA": ("Alpha-Tocopherol", "mg", "vitamins"),
|
||||
"TOCPHB": ("Beta-Tocopherol", "mg", "vitamins"),
|
||||
"TOCPHG": ("Gamma-Tocopherol", "mg", "vitamins"),
|
||||
"TOCPHD": ("Delta-Tocopherol", "mg", "vitamins"),
|
||||
"TOCTRA": ("Alpha-Tocotrienol", "mg", "vitamins"),
|
||||
"VITK": ("Vitamin K", "µg", "vitamins"),
|
||||
"VITK1": ("Vitamin K1 (Phyllochinon)", "µg", "vitamins"),
|
||||
"VITK2": ("Vitamin K2 (Menachinone)", "µg", "vitamins"),
|
||||
"THIA": ("Vitamin B1 (Thiamin)", "mg", "vitamins"),
|
||||
"RIBF": ("Vitamin B2 (Riboflavin)", "mg", "vitamins"),
|
||||
"NIA": ("Niacin", "mg", "vitamins"),
|
||||
"NIAEQ": ("Niacin-Äquivalent", "mg", "vitamins"),
|
||||
"PANTAC": ("Pantothensäure", "mg", "vitamins"),
|
||||
"VITB6": ("Vitamin B6", "µg", "vitamins"),
|
||||
"BIOT": ("Biotin", "µg", "vitamins"),
|
||||
"FOL": ("Folat-Äquivalent", "µg", "vitamins"),
|
||||
"FOLFD": ("Folat", "µg", "vitamins"),
|
||||
"FOLAC": ("Folsäure, synthetisch", "µg", "vitamins"),
|
||||
"VITB12": ("Vitamin B12 (Cobalamine)", "µg", "vitamins"),
|
||||
"VITC": ("Vitamin C", "mg", "vitamins"),
|
||||
"NACL": ("Salz (Natriumchlorid)", "g", "minerals"),
|
||||
"NA": ("Natrium", "mg", "minerals"),
|
||||
"CLD": ("Chlorid", "mg", "minerals"),
|
||||
"K": ("Kalium", "mg", "minerals"),
|
||||
"CA": ("Calcium", "mg", "minerals"),
|
||||
"MG": ("Magnesium", "mg", "minerals"),
|
||||
"P": ("Phosphor", "mg", "minerals"),
|
||||
"S": ("Schwefel", "mg", "minerals"),
|
||||
"FE": ("Eisen", "mg", "minerals"),
|
||||
"ZN": ("Zink", "mg", "minerals"),
|
||||
"ID": ("Iodid", "µg", "minerals"),
|
||||
"CU": ("Kupfer", "µg", "minerals"),
|
||||
"MN": ("Mangan", "µg", "minerals"),
|
||||
"FD": ("Fluorid", "µg", "minerals"),
|
||||
"CR": ("Chrom", "µg", "minerals"),
|
||||
"MO": ("Molybdän", "µg", "minerals"),
|
||||
"ACEAC": ("Essigsäure", "g", "organic_acids"),
|
||||
"CITAC": ("Zitronensäure", "g", "organic_acids"),
|
||||
"LACAC": ("Milchsäure", "g", "organic_acids"),
|
||||
"MALAC": ("Äpfelsäure", "g", "organic_acids"),
|
||||
"TARAC": ("Weinsäure", "g", "organic_acids"),
|
||||
"MANTL": ("Mannit", "g", "polyols"),
|
||||
"SORTL": ("Sorbit", "g", "polyols"),
|
||||
"XYLTL": ("Xylit", "g", "polyols"),
|
||||
"CHORL": ("Cholesterin", "mg", "other"),
|
||||
"NT": ("Stickstoff, gesamt", "g", "other"),
|
||||
"EPA": ("Eicosapentaensäure (EPA)", "g", "fatty_acids"),
|
||||
"DHA": ("Docosahexaensäure (DHA)", "g", "fatty_acids"),
|
||||
"DPA": ("Docosapentaensäure (DPA)", "g", "fatty_acids"),
|
||||
"ALA": ("Alpha-Linolensäure (ALA)", "g", "fatty_acids"),
|
||||
"OMEGA3": ("Omega-3-Fettsäuren gesamt", "g", "fatty_acids"),
|
||||
"OMEGA6": ("Omega-6-Fettsäuren gesamt", "g", "fatty_acids"),
|
||||
}
|
||||
|
||||
|
||||
def lookup_key(raw: str | None) -> str:
|
||||
return str(raw or "").strip().upper()
|
||||
|
||||
|
||||
def resolve_group(attr_key: str | None, category: str | None = None, origin: str | None = None) -> tuple[str, str]:
|
||||
cat = (category or "").strip().lower()
|
||||
if cat in GROUP_LABELS:
|
||||
return cat, GROUP_LABELS[cat]
|
||||
lab = official_label(attr_key)
|
||||
if lab and lab["category"] in GROUP_LABELS:
|
||||
return lab["category"], GROUP_LABELS[lab["category"]]
|
||||
if (origin or "") == "extension":
|
||||
return "extension", GROUP_LABELS["extension"]
|
||||
return "unknown", GROUP_LABELS["unknown"]
|
||||
|
||||
|
||||
def annotate_attribute(row: dict[str, Any]) -> dict[str, Any]:
|
||||
key = row.get("attr_key")
|
||||
group_key, group_label = resolve_group(key, row.get("category"), row.get("origin"))
|
||||
row["is_macro"] = key in MACRO_KEYS
|
||||
row["needs_label"] = label_is_placeholder(row.get("name_de"), key)
|
||||
row["group_key"] = group_key
|
||||
row["group_label"] = group_label
|
||||
row["group_rank"] = GROUP_RANK[group_key]
|
||||
return row
|
||||
|
||||
|
||||
def sort_attributes(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
def key_of(row: dict[str, Any]):
|
||||
return (
|
||||
row.get("group_rank", 99),
|
||||
0 if row.get("is_macro") else 1,
|
||||
row.get("sort_order") if row.get("sort_order") is not None else 0,
|
||||
(row.get("name_de") or row.get("attr_key") or "").casefold(),
|
||||
row.get("attr_key") or "",
|
||||
)
|
||||
|
||||
return sorted(rows, key=key_of)
|
||||
|
||||
|
||||
def official_label(attr_key: str | None) -> dict[str, str] | None:
|
||||
key = lookup_key(attr_key)
|
||||
hit = _OFFICIAL.get(key)
|
||||
if not hit:
|
||||
hit = _OFFICIAL.get(key.replace("_", ":"))
|
||||
if not hit:
|
||||
return None
|
||||
name_de, unit, category = hit
|
||||
return {"name_de": name_de, "unit": unit, "category": category}
|
||||
|
||||
|
||||
def label_is_placeholder(name_de: str | None, attr_key: str | None) -> bool:
|
||||
name = (name_de or "").strip()
|
||||
key = lookup_key(attr_key)
|
||||
return (not name) or name.upper() == key
|
||||
|
||||
|
||||
def apply_official_name(attr_key: str | None, name_de: str | None) -> str:
|
||||
if not label_is_placeholder(name_de, attr_key):
|
||||
return (name_de or "").strip()
|
||||
lab = official_label(attr_key)
|
||||
if lab:
|
||||
return lab["name_de"]
|
||||
return (name_de or "").strip() or lookup_key(attr_key)
|
||||
|
||||
|
||||
def enrich_attributes_in_memory(attributes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
for a in attributes:
|
||||
key = a.get("attr_key")
|
||||
lab = official_label(key)
|
||||
if label_is_placeholder(a.get("name_de"), key) and lab:
|
||||
a["name_de"] = lab["name_de"]
|
||||
a["unit"] = a.get("unit") or lab["unit"]
|
||||
a["category"] = a.get("category") or lab["category"]
|
||||
return attributes
|
||||
|
||||
|
||||
def fill_placeholder_labels(cur) -> dict[str, int]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, attr_key, name_de, unit, category
|
||||
FROM food_attributes
|
||||
WHERE is_active = true
|
||||
"""
|
||||
)
|
||||
updated = 0
|
||||
for row in cur.fetchall():
|
||||
rec = dict(row)
|
||||
lab = official_label(rec.get("attr_key"))
|
||||
if not lab:
|
||||
continue
|
||||
need_name = label_is_placeholder(rec.get("name_de"), rec.get("attr_key"))
|
||||
need_cat = not (rec.get("category") or "").strip()
|
||||
if not need_name and not need_cat:
|
||||
continue
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE food_attributes
|
||||
SET name_de = %s,
|
||||
unit = COALESCE(NULLIF(BTRIM(COALESCE(unit, '')), ''), %s),
|
||||
category = COALESCE(NULLIF(BTRIM(COALESCE(category, '')), ''), %s),
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
lab["name_de"] if need_name else rec.get("name_de"),
|
||||
lab["unit"],
|
||||
lab["category"],
|
||||
rec["id"],
|
||||
),
|
||||
)
|
||||
updated += 1
|
||||
return {"updated": updated}
|
||||
|
||||
|
||||
def enrich_labels_from_food_headers(cur, headers: list[dict[str, Any]] | None) -> int:
|
||||
updated = 0
|
||||
for h in headers or []:
|
||||
key = h.get("attr_key")
|
||||
name = (h.get("name_de") or "").strip()
|
||||
unit = (h.get("unit") or "").strip() or None
|
||||
if not key or not name or label_is_placeholder(name, key):
|
||||
lab = official_label(key)
|
||||
if not lab:
|
||||
continue
|
||||
name = lab["name_de"]
|
||||
unit = unit or lab["unit"]
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE food_attributes
|
||||
SET name_de = %s,
|
||||
unit = COALESCE(%s, unit),
|
||||
updated_at = NOW()
|
||||
WHERE attr_key = %s
|
||||
AND (
|
||||
name_de IS NULL OR BTRIM(name_de) = '' OR name_de = attr_key
|
||||
)
|
||||
""",
|
||||
(name, unit, key),
|
||||
)
|
||||
if cur.rowcount:
|
||||
updated += cur.rowcount
|
||||
return updated
|
||||
|
|
@ -16,6 +16,9 @@ def should_persist_value(val: dict[str, Any]) -> bool:
|
|||
|
||||
|
||||
def upsert_attributes(cur, attributes: list[dict[str, Any]]) -> dict[str, int]:
|
||||
from bls.attribute_labels import enrich_attributes_in_memory
|
||||
|
||||
attributes = enrich_attributes_in_memory(list(attributes))
|
||||
inserted = updated = 0
|
||||
for a in attributes:
|
||||
cur.execute(
|
||||
|
|
@ -47,7 +50,15 @@ def upsert_attributes(cur, attributes: list[dict[str, Any]]) -> dict[str, int]:
|
|||
return {"inserted": inserted, "updated": updated, "total": len(attributes)}
|
||||
|
||||
|
||||
def upsert_foods(cur, foods: list[dict[str, Any]], bls_version: str = "4.0") -> dict[str, int]:
|
||||
def upsert_foods(
|
||||
cur,
|
||||
foods: list[dict[str, Any]],
|
||||
bls_version: str = "4.0",
|
||||
attribute_headers: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, int]:
|
||||
if attribute_headers:
|
||||
from bls.attribute_labels import enrich_labels_from_food_headers
|
||||
enrich_labels_from_food_headers(cur, attribute_headers)
|
||||
cur.execute("SELECT attr_key, id FROM food_attributes")
|
||||
attr_ids = {r["attr_key"]: r["id"] for r in cur.fetchall()}
|
||||
codes = [f["bls_code"] for f in foods if f.get("bls_code")]
|
||||
|
|
|
|||
|
|
@ -146,10 +146,15 @@ def _run_apply(job_id: str) -> None:
|
|||
total = len(foods)
|
||||
inserted = updated = values_written = 0
|
||||
_update(job_id, progress={"current": 0, "total": total})
|
||||
headers = parsed.get("attribute_headers")
|
||||
for i in range(0, total, FOOD_CHUNK):
|
||||
chunk = foods[i : i + FOOD_CHUNK]
|
||||
with get_db() as conn:
|
||||
stats = upsert_foods(get_cursor(conn), chunk)
|
||||
stats = upsert_foods(
|
||||
get_cursor(conn),
|
||||
chunk,
|
||||
attribute_headers=headers if i == 0 else None,
|
||||
)
|
||||
inserted += stats.get("inserted", 0)
|
||||
updated += stats.get("updated", 0)
|
||||
values_written += stats.get("values_written", 0)
|
||||
|
|
|
|||
|
|
@ -26,25 +26,34 @@ def parse_components_xlsx(data: bytes) -> list[dict[str, Any]]:
|
|||
header = [_cell(c) for c in next(rows)]
|
||||
idx = {h.lower(): i for i, h in enumerate(header) if h}
|
||||
|
||||
def col(*names: str) -> int | None:
|
||||
def col(*names: str, exclude: tuple[str, ...] = ()) -> int | None:
|
||||
for n in names:
|
||||
if n.lower() in idx:
|
||||
return idx[n.lower()]
|
||||
for key, i in idx.items():
|
||||
if any(ex in key for ex in exclude):
|
||||
continue
|
||||
for n in names:
|
||||
if n.lower() in key:
|
||||
return i
|
||||
return None
|
||||
|
||||
i_code = col("code", "schlüssel", "schluessel", "attr_key", "komponente")
|
||||
i_de = col("name_de", "deutsch", "bezeichnung_de", "name de")
|
||||
i_en = col("name_en", "english", "bezeichnung_en", "name en")
|
||||
i_code = col(
|
||||
"component code", "komponentencode", "attr_key", "schlüssel", "schluessel",
|
||||
"code", "komponente",
|
||||
)
|
||||
i_de = col(
|
||||
"name_de", "bezeichnung_de", "bezeichnung deutsch", "name de",
|
||||
"deutsch", "german", "bezeichnung",
|
||||
exclude=("name_en", "bezeichnung_en", "english", "englisch", "name en"),
|
||||
)
|
||||
i_en = col("name_en", "bezeichnung_en", "name en", "english", "englisch")
|
||||
i_unit = col("unit", "einheit")
|
||||
i_cat = col("category", "kategorie", "gruppe")
|
||||
if i_code is None:
|
||||
i_code = 0
|
||||
if i_de is None:
|
||||
i_de = 1 if len(header) > 1 else 0
|
||||
|
||||
from bls.attribute_labels import apply_official_name
|
||||
|
||||
out = []
|
||||
sort_order = 0
|
||||
|
|
@ -52,9 +61,10 @@ def parse_components_xlsx(data: bytes) -> list[dict[str, Any]]:
|
|||
if not raw:
|
||||
continue
|
||||
code = _cell(raw[i_code] if i_code < len(raw) else "")
|
||||
if not code or code.lower() in ("code", "schlüssel", "schluessel"):
|
||||
if not code or code.lower() in ("code", "schlüssel", "schluessel", "component code"):
|
||||
continue
|
||||
name_de = _cell(raw[i_de] if i_de is not None and i_de < len(raw) else "") or code
|
||||
raw_de = _cell(raw[i_de] if i_de is not None and i_de < len(raw) else "")
|
||||
name_de = apply_official_name(code, raw_de)
|
||||
name_en = _cell(raw[i_en] if i_en is not None and i_en < len(raw) else "") or None
|
||||
unit = _cell(raw[i_unit] if i_unit is not None and i_unit < len(raw) else "") or None
|
||||
category = _cell(raw[i_cat] if i_cat is not None and i_cat < len(raw) else "") or None
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
"""Catalog attribute definitions and numeric values (BLS EAV + extensions)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_ATTR_KEY_RE = re.compile(r"[^A-Z0-9]+")
|
||||
_ALLOWED_UNITS = {"g": "g", "mg": "mg", "µg": "µg", "ug": "µg", "mcg": "µg", "kcal": "kcal", "kj": "kJ"}
|
||||
|
||||
MACRO_TO_KEY = {
|
||||
"kcal": "ENERCC",
|
||||
"protein_g": "PROT625",
|
||||
|
|
@ -17,9 +21,24 @@ def scale_to_per_100g(value: float, serving_g: float | None) -> float:
|
|||
return float(value) * (100.0 / float(serving_g))
|
||||
|
||||
|
||||
def list_numeric_attributes(cur, query: str = "", limit: int = 40) -> list[dict[str, Any]]:
|
||||
q = (query or "").strip()
|
||||
def list_numeric_attributes(
|
||||
cur, query: str = "", limit: int = 40, keys: list[str] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
lim = min(max(int(limit or 40), 1), 80)
|
||||
wanted = [str(k).strip() for k in (keys or []) if str(k).strip()]
|
||||
if wanted:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, attr_key, name_de, name_en, unit, category, origin
|
||||
FROM food_attributes
|
||||
WHERE is_active = true AND data_type = 'num_per_100g'
|
||||
AND attr_key = ANY(%s)
|
||||
ORDER BY sort_order, attr_key
|
||||
""",
|
||||
(wanted,),
|
||||
)
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
q = (query or "").strip()
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
cur.execute(
|
||||
|
|
@ -89,6 +108,57 @@ def write_numeric_attributes(cur, food_id: str, values: dict[str, Any] | None) -
|
|||
return written
|
||||
|
||||
|
||||
def replace_numeric_attributes(cur, food_id: str, values: dict[str, Any] | None) -> int:
|
||||
cur.execute("DELETE FROM food_attribute_values WHERE food_id = %s", (food_id,))
|
||||
return write_numeric_attributes(cur, food_id, values)
|
||||
|
||||
|
||||
KEY_TO_MACRO = {v: k for k, v in MACRO_TO_KEY.items()}
|
||||
|
||||
|
||||
def editor_payload_for_food(cur, food: dict[str, Any], *, editable: bool) -> dict[str, Any]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT a.attr_key, a.name_de, a.name_en, a.unit, a.category, a.origin, v.value_num
|
||||
FROM food_attribute_values v
|
||||
JOIN food_attributes a ON a.id = v.attribute_id
|
||||
WHERE v.food_id = %s AND a.data_type = 'num_per_100g'
|
||||
AND v.value_num IS NOT NULL AND v.is_trace = false
|
||||
ORDER BY a.sort_order, a.attr_key
|
||||
""",
|
||||
(food["id"],),
|
||||
)
|
||||
macros = {"kcal": 0, "protein_g": 0, "fat_g": 0, "carbs_g": 0}
|
||||
extras = []
|
||||
for row in cur.fetchall():
|
||||
rec = dict(row)
|
||||
field = KEY_TO_MACRO.get(rec["attr_key"])
|
||||
if field:
|
||||
macros[field] = float(rec["value_num"])
|
||||
else:
|
||||
extras.append({
|
||||
"attr": {
|
||||
"attr_key": rec["attr_key"],
|
||||
"name_de": rec["name_de"],
|
||||
"name_en": rec["name_en"],
|
||||
"unit": rec["unit"],
|
||||
"category": rec["category"],
|
||||
"origin": rec["origin"],
|
||||
},
|
||||
"value": float(rec["value_num"]),
|
||||
})
|
||||
return {
|
||||
"id": str(food["id"]),
|
||||
"name_de": food.get("name_de") or "",
|
||||
"name_en": food.get("name_en"),
|
||||
"catalog_kind": food.get("catalog_kind"),
|
||||
"editable": editable,
|
||||
"macros_per_100g": macros,
|
||||
"attributes": extras,
|
||||
"serving_g": 100,
|
||||
}
|
||||
|
||||
|
||||
def macros_and_attributes_to_values(
|
||||
macros: dict[str, Any] | None,
|
||||
attributes: dict[str, Any] | None,
|
||||
|
|
@ -126,3 +196,43 @@ def extra_attributes_for_food(cur, food_id: str) -> dict[str, float]:
|
|||
(food_id, list(MACRO_TO_KEY.values())),
|
||||
)
|
||||
return {r["attr_key"]: float(r["value_num"]) for r in cur.fetchall()}
|
||||
|
||||
|
||||
def normalize_attr_key(raw: str | None) -> str:
|
||||
key = _ATTR_KEY_RE.sub("_", (raw or "").strip().upper()).strip("_")
|
||||
return key[:64]
|
||||
|
||||
|
||||
def normalize_attr_unit(raw: str | None) -> str:
|
||||
return _ALLOWED_UNITS.get((raw or "g").strip().lower(), "g")
|
||||
|
||||
|
||||
def create_extension_attribute(cur, name_de: str, unit: str = "g", attr_key: str | None = None) -> dict[str, Any]:
|
||||
name = (name_de or "").strip()
|
||||
if not name:
|
||||
raise ValueError("Name fehlt")
|
||||
key = normalize_attr_key(attr_key or name)
|
||||
if not key:
|
||||
raise ValueError("attr_key fehlt")
|
||||
unit_n = normalize_attr_unit(unit)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, attr_key, name_de, name_en, unit, category, origin
|
||||
FROM food_attributes
|
||||
WHERE attr_key = %s AND is_active = true
|
||||
""",
|
||||
(key,),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
return dict(existing)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_attributes
|
||||
(attr_key, name_de, name_en, unit, category, data_type, origin, sort_order)
|
||||
VALUES (%s, %s, %s, %s, 'extension', 'num_per_100g', 'extension', 9100)
|
||||
RETURNING id, attr_key, name_de, name_en, unit, category, origin
|
||||
""",
|
||||
(key, name, None, unit_n),
|
||||
)
|
||||
return dict(cur.fetchone())
|
||||
|
|
|
|||
|
|
@ -408,14 +408,16 @@ def get_food_mapping_with_cursor(
|
|||
"""
|
||||
SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source,
|
||||
m.grams_per_unit, m.source_unit,
|
||||
f.bls_code, f.name_de, f.catalog_kind
|
||||
f.bls_code, f.name_de, f.catalog_kind, f.owner_profile_id
|
||||
FROM food_name_mappings m
|
||||
JOIN food_catalog f ON f.id = m.food_id
|
||||
WHERE m.source_system = %s AND m.source_name_normalized = %s
|
||||
AND m.profile_id = %s
|
||||
AND f.is_active = true
|
||||
AND (f.owner_profile_id IS NULL OR f.owner_profile_id = %s)
|
||||
LIMIT 1
|
||||
""",
|
||||
(source_system, norm, profile_id),
|
||||
(source_system, norm, profile_id, profile_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
|
|
@ -424,11 +426,13 @@ def get_food_mapping_with_cursor(
|
|||
"""
|
||||
SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source,
|
||||
m.grams_per_unit, m.source_unit,
|
||||
f.bls_code, f.name_de, f.catalog_kind
|
||||
f.bls_code, f.name_de, f.catalog_kind, f.owner_profile_id
|
||||
FROM food_name_mappings m
|
||||
JOIN food_catalog f ON f.id = m.food_id
|
||||
WHERE m.source_system = %s AND m.source_name_normalized = %s
|
||||
AND m.profile_id IS NULL
|
||||
AND f.is_active = true
|
||||
AND f.owner_profile_id IS NULL
|
||||
LIMIT 1
|
||||
""",
|
||||
(source_system, norm),
|
||||
|
|
@ -448,6 +452,9 @@ def upsert_food_mapping(
|
|||
grams_per_unit: float | None = None,
|
||||
source_unit: str | None = None,
|
||||
) -> int:
|
||||
from data_layer.food_visibility import require_mapping_target
|
||||
|
||||
require_mapping_target(cur, food_id, profile_id)
|
||||
norm = normalize_food_name(source_name_raw)
|
||||
if not norm:
|
||||
raise ValueError("Leerer Lebensmittelname")
|
||||
|
|
@ -622,7 +629,7 @@ def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int =
|
|||
extra_sql = " OR (" + " AND ".join(parts) + ")"
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group
|
||||
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group, owner_profile_id
|
||||
FROM food_catalog
|
||||
WHERE is_active = true
|
||||
AND (
|
||||
|
|
@ -642,6 +649,10 @@ def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int =
|
|||
WHEN name_de ILIKE %s THEN 1
|
||||
WHEN COALESCE(bls_code, '') ILIKE %s THEN 2
|
||||
ELSE 3 END,
|
||||
CASE catalog_kind
|
||||
WHEN 'manual_user' THEN 0
|
||||
WHEN 'manual_admin' THEN 1
|
||||
ELSE 2 END,
|
||||
name_de
|
||||
LIMIT %s
|
||||
""",
|
||||
|
|
|
|||
|
|
@ -131,6 +131,8 @@ def score_name_match(query: str, name_de: str, name_en: str | None = None) -> in
|
|||
|
||||
|
||||
def _public(food: dict[str, Any], score: int) -> dict[str, Any]:
|
||||
from data_layer.food_visibility import visibility_of
|
||||
|
||||
return {
|
||||
"id": str(food["id"]),
|
||||
"bls_code": food.get("bls_code"),
|
||||
|
|
@ -138,6 +140,7 @@ def _public(food: dict[str, Any], score: int) -> dict[str, Any]:
|
|||
"name_en": food.get("name_en"),
|
||||
"catalog_kind": food.get("catalog_kind"),
|
||||
"food_group": food.get("food_group"),
|
||||
"visibility": visibility_of(food),
|
||||
"score": score,
|
||||
}
|
||||
|
||||
|
|
@ -163,7 +166,7 @@ def get_suggest_index(cur, profile_id: str | None) -> dict[str, Any]:
|
|||
def load_suggest_index(cur, profile_id: str | None) -> dict[str, Any]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group
|
||||
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group, owner_profile_id
|
||||
FROM food_catalog
|
||||
WHERE is_active = true
|
||||
AND (owner_profile_id IS NULL OR owner_profile_id = %s)
|
||||
|
|
@ -245,6 +248,11 @@ def _candidate_foods(index: dict[str, Any], query: str) -> list[dict[str, Any]]:
|
|||
return out[:MAX_CANDIDATES] if not number_tokens(query) else out
|
||||
|
||||
|
||||
def _vis_rank(food: dict[str, Any]) -> int:
|
||||
from data_layer.food_visibility import visibility_rank
|
||||
return visibility_rank(food)
|
||||
|
||||
|
||||
def suggest_for_name(index: dict[str, Any], query: str, limit: int = 3) -> dict[str, Any]:
|
||||
q = (query or "").strip()
|
||||
scored: list[tuple[int, int, dict]] = []
|
||||
|
|
@ -253,7 +261,7 @@ def suggest_for_name(index: dict[str, Any], query: str, limit: int = 3) -> dict[
|
|||
if score < MIN_SCORE:
|
||||
continue
|
||||
scored.append((score, len(food.get("name_de") or ""), food))
|
||||
scored.sort(key=lambda x: (-x[0], x[1], x[2].get("name_de") or ""))
|
||||
scored.sort(key=lambda x: (-x[0], _vis_rank(x[2]), x[1], x[2].get("name_de") or ""))
|
||||
top = [_public(food, score) for score, _nlen, food in scored[: max(limit, 3)]]
|
||||
ambiguous = False
|
||||
if len(top) >= 2 and top[0]["score"] - top[1]["score"] <= 8 and top[1]["score"] >= 60:
|
||||
|
|
@ -296,7 +304,7 @@ def suggest_catalog_foods_ranked(cur, query: str, profile_id: str | None, limit:
|
|||
if score < MIN_SCORE and not number_tokens(q):
|
||||
continue
|
||||
hits.append(_public(row, score or 40))
|
||||
hits.sort(key=lambda h: (-int(h.get("score") or 0), h.get("name_de") or ""))
|
||||
hits.sort(key=lambda h: (-int(h.get("score") or 0), _vis_rank(h), h.get("name_de") or ""))
|
||||
return hits[:limit]
|
||||
|
||||
|
||||
|
|
|
|||
121
backend/data_layer/food_visibility.py
Normal file
121
backend/data_layer/food_visibility.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Who may see and map a catalog food.
|
||||
|
||||
Scopes:
|
||||
- personal — ``manual_user`` with ``owner_profile_id`` (only that profile)
|
||||
- global — ``manual_admin`` (all users; no owner)
|
||||
- bls — official BLS (all users; no owner)
|
||||
|
||||
Name-mappings: a *user* mapping may target any food visible to that user.
|
||||
A *global* mapping (``profile_id IS NULL``) may only target global/BLS foods,
|
||||
never another user's private entry.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
VIS_PERSONAL = "personal"
|
||||
VIS_GLOBAL = "global"
|
||||
VIS_BLS = "bls"
|
||||
|
||||
SQL_VISIBLE_TO_PROFILE = "(owner_profile_id IS NULL OR owner_profile_id = %s)"
|
||||
SQL_GLOBAL_ONLY = "owner_profile_id IS NULL"
|
||||
|
||||
_KIND_VIS = {
|
||||
"official_bls": VIS_BLS,
|
||||
"manual_admin": VIS_GLOBAL,
|
||||
"manual_user": VIS_PERSONAL,
|
||||
}
|
||||
|
||||
_VIS_RANK = {VIS_PERSONAL: 0, VIS_GLOBAL: 1, VIS_BLS: 2}
|
||||
|
||||
|
||||
def same_id(a, b) -> bool:
|
||||
return str(a or "").replace("-", "").lower() == str(b or "").replace("-", "").lower()
|
||||
|
||||
|
||||
def visibility_of(food: dict[str, Any] | None) -> str:
|
||||
if not food:
|
||||
return VIS_BLS
|
||||
kind = food.get("catalog_kind")
|
||||
if kind in _KIND_VIS:
|
||||
return _KIND_VIS[kind]
|
||||
if food.get("owner_profile_id"):
|
||||
return VIS_PERSONAL
|
||||
return VIS_GLOBAL
|
||||
|
||||
|
||||
def visibility_rank(food: dict[str, Any] | None) -> int:
|
||||
return _VIS_RANK.get(visibility_of(food), 3)
|
||||
|
||||
|
||||
def is_active(food: dict[str, Any] | None) -> bool:
|
||||
if not food:
|
||||
return False
|
||||
if "is_active" not in food:
|
||||
return True
|
||||
return bool(food.get("is_active"))
|
||||
|
||||
|
||||
def is_visible_to(food: dict[str, Any] | None, profile_id: str | None) -> bool:
|
||||
if not is_active(food):
|
||||
return False
|
||||
owner = food.get("owner_profile_id")
|
||||
if owner is None:
|
||||
return True
|
||||
return profile_id is not None and same_id(owner, profile_id)
|
||||
|
||||
|
||||
def is_global_mapping_target(food: dict[str, Any] | None) -> bool:
|
||||
return is_active(food) and food.get("owner_profile_id") is None
|
||||
|
||||
|
||||
def load_catalog_food(cur, food_id: str) -> dict[str, Any] | None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name_de, catalog_kind, owner_profile_id, is_active, bls_code
|
||||
FROM food_catalog
|
||||
WHERE id = %s
|
||||
""",
|
||||
(food_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def require_visible_food(cur, food_id: str, profile_id: str) -> dict[str, Any]:
|
||||
food = load_catalog_food(cur, food_id)
|
||||
if not is_visible_to(food, profile_id):
|
||||
raise LookupError("Lebensmittel nicht gefunden")
|
||||
return food
|
||||
|
||||
|
||||
def require_global_mapping_target(cur, food_id: str) -> dict[str, Any]:
|
||||
food = load_catalog_food(cur, food_id)
|
||||
if not food or not is_active(food):
|
||||
raise LookupError("Lebensmittel nicht gefunden")
|
||||
if not is_global_mapping_target(food):
|
||||
raise PermissionError("Globales Mapping darf nur auf zentrale oder BLS-Lebensmittel zeigen")
|
||||
return food
|
||||
|
||||
|
||||
def require_mapping_target(cur, food_id: str, mapping_profile_id: str | None) -> dict[str, Any]:
|
||||
if mapping_profile_id:
|
||||
return require_visible_food(cur, food_id, mapping_profile_id)
|
||||
return require_global_mapping_target(cur, food_id)
|
||||
|
||||
|
||||
def can_edit_food(food: dict[str, Any] | None, profile_id: str | None, *, is_admin: bool = False) -> bool:
|
||||
"""Owner ändert persönlich; Admin ändert zentral (und im Admin-Katalog auch fremde Manuals)."""
|
||||
if not is_active(food):
|
||||
return False
|
||||
kind = food.get("catalog_kind")
|
||||
if kind == "manual_user":
|
||||
return same_id(food.get("owner_profile_id"), profile_id) or bool(is_admin)
|
||||
if kind == "manual_admin":
|
||||
return bool(is_admin)
|
||||
return False
|
||||
|
||||
|
||||
def attach_visibility(row: dict[str, Any]) -> dict[str, Any]:
|
||||
row["visibility"] = visibility_of(row)
|
||||
return row
|
||||
|
|
@ -487,3 +487,45 @@ def dates_for_normalized_name(cur, profile_id: str, source_name_normalized: str)
|
|||
(profile_id, source_name_normalized, profile_id, source_name_normalized),
|
||||
)
|
||||
return [r["date"] for r in cur.fetchall()]
|
||||
|
||||
|
||||
def dates_using_food(cur, profile_id: str, food_id: str) -> list[str]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT date::text AS date
|
||||
FROM nutrition_items
|
||||
WHERE profile_id = %s AND food_id = %s
|
||||
UNION
|
||||
SELECT DISTINCT i.date::text AS date
|
||||
FROM nutrition_items i
|
||||
JOIN food_recipe_ingredients ri ON ri.recipe_id = i.recipe_id
|
||||
JOIN food_name_mappings m
|
||||
ON m.profile_id = i.profile_id
|
||||
AND m.source_name_normalized = ri.source_name_normalized
|
||||
AND m.food_id = %s
|
||||
WHERE i.profile_id = %s AND i.recipe_id IS NOT NULL
|
||||
""",
|
||||
(profile_id, food_id, food_id, profile_id),
|
||||
)
|
||||
return [r["date"] for r in cur.fetchall() if r.get("date")]
|
||||
|
||||
|
||||
def profiles_dates_using_food(cur, food_id: str) -> list[tuple[str, str]]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT profile_id::text AS profile_id, date::text AS date
|
||||
FROM nutrition_items
|
||||
WHERE food_id = %s
|
||||
UNION
|
||||
SELECT DISTINCT i.profile_id::text AS profile_id, i.date::text AS date
|
||||
FROM nutrition_items i
|
||||
JOIN food_recipe_ingredients ri ON ri.recipe_id = i.recipe_id
|
||||
JOIN food_name_mappings m
|
||||
ON m.profile_id = i.profile_id
|
||||
AND m.source_name_normalized = ri.source_name_normalized
|
||||
AND m.food_id = %s
|
||||
WHERE i.recipe_id IS NOT NULL
|
||||
""",
|
||||
(food_id, food_id),
|
||||
)
|
||||
return [(r["profile_id"], r["date"]) for r in cur.fetchall() if r.get("profile_id") and r.get("date")]
|
||||
|
|
|
|||
18
backend/migrations/069_food_catalog_visibility.sql
Normal file
18
backend/migrations/069_food_catalog_visibility.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
-- Visibility invariant: personal foods have an owner; BLS and central admin foods do not.
|
||||
|
||||
UPDATE food_catalog
|
||||
SET owner_profile_id = NULL
|
||||
WHERE catalog_kind IN ('official_bls', 'manual_admin')
|
||||
AND owner_profile_id IS NOT NULL;
|
||||
|
||||
UPDATE food_catalog
|
||||
SET is_active = false
|
||||
WHERE catalog_kind = 'manual_user'
|
||||
AND owner_profile_id IS NULL;
|
||||
|
||||
ALTER TABLE food_catalog DROP CONSTRAINT IF EXISTS chk_food_catalog_owner;
|
||||
|
||||
ALTER TABLE food_catalog ADD CONSTRAINT chk_food_catalog_owner CHECK (
|
||||
(catalog_kind = 'manual_user' AND owner_profile_id IS NOT NULL)
|
||||
OR (catalog_kind IN ('official_bls', 'manual_admin') AND owner_profile_id IS NULL)
|
||||
);
|
||||
|
|
@ -28,10 +28,20 @@ class AttributeCreate(BaseModel):
|
|||
enum_values: Optional[list] = None
|
||||
|
||||
|
||||
class AttributeUpdate(BaseModel):
|
||||
name_de: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class ManualFoodCreate(BaseModel):
|
||||
name_de: str
|
||||
name_en: Optional[str] = None
|
||||
macros_per_100g: Optional[dict] = None
|
||||
attributes: Optional[dict] = None
|
||||
serving_g: Optional[float] = None
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
|
|
@ -162,7 +172,9 @@ def admin_list_foods(
|
|||
cur = get_cursor(conn)
|
||||
conds = ["is_active = true"]
|
||||
params: list = []
|
||||
if kind:
|
||||
if kind == "manual":
|
||||
conds.append("catalog_kind IN ('manual_user', 'manual_admin')")
|
||||
elif kind:
|
||||
conds.append("catalog_kind = %s")
|
||||
params.append(kind)
|
||||
if q:
|
||||
|
|
@ -172,7 +184,7 @@ def admin_list_foods(
|
|||
params.append(limit)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group, bls_version
|
||||
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group, bls_version, owner_profile_id
|
||||
FROM food_catalog
|
||||
WHERE {' AND '.join(conds)}
|
||||
ORDER BY name_de
|
||||
|
|
@ -184,7 +196,7 @@ def admin_list_foods(
|
|||
|
||||
|
||||
@router.get("/foods/{food_id}")
|
||||
def admin_food_detail(food_id: str, session: dict = Depends(require_admin)):
|
||||
def admin_food_detail(food_id: str, editor: bool = False, session: dict = Depends(require_admin)):
|
||||
from data_layer.nutrition_items import resolve_food_attributes
|
||||
|
||||
with get_db() as conn:
|
||||
|
|
@ -193,17 +205,28 @@ def admin_food_detail(food_id: str, session: dict = Depends(require_admin)):
|
|||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Lebensmittel nicht gefunden")
|
||||
food = dict(row)
|
||||
if editor:
|
||||
from data_layer.food_attributes import editor_payload_for_food
|
||||
from data_layer.food_visibility import attach_visibility
|
||||
|
||||
editable = food.get("catalog_kind") in ("manual_admin", "manual_user")
|
||||
return attach_visibility(editor_payload_for_food(cur, food, editable=editable))
|
||||
attrs = resolve_food_attributes(cur, food_id)
|
||||
return {**r2d(row), "attributes": attrs}
|
||||
|
||||
|
||||
@router.post("/foods/manual")
|
||||
def admin_create_manual_food(body: ManualFoodCreate, session: dict = Depends(require_admin)):
|
||||
from data_layer.food_attributes import macros_and_attributes_to_values, write_numeric_attributes
|
||||
from data_layer.food_mapping import normalize_food_name
|
||||
from data_layer.food_visibility import attach_visibility
|
||||
|
||||
name = (body.name_de or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "Name fehlt")
|
||||
serving = body.serving_g if body.serving_g and body.serving_g > 0 else None
|
||||
values = macros_and_attributes_to_values(body.macros_per_100g, body.attributes, serving)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
|
|
@ -215,14 +238,70 @@ def admin_create_manual_food(body: ManualFoodCreate, session: dict = Depends(req
|
|||
(name, body.name_en, f"man-admin-{normalize_food_name(name)[:40]}"),
|
||||
)
|
||||
food = r2d(cur.fetchone())
|
||||
_write_manual_macros(cur, food["id"], body.macros_per_100g)
|
||||
write_numeric_attributes(cur, food["id"], values)
|
||||
from data_layer.food_suggest import invalidate_suggest_index
|
||||
invalidate_suggest_index()
|
||||
return food
|
||||
return attach_visibility(food)
|
||||
|
||||
|
||||
@router.put("/foods/manual/{food_id}")
|
||||
def admin_update_manual_food(food_id: str, body: ManualFoodCreate, session: dict = Depends(require_admin)):
|
||||
from collections import defaultdict
|
||||
|
||||
from data_layer.food_attributes import (
|
||||
editor_payload_for_food,
|
||||
macros_and_attributes_to_values,
|
||||
replace_numeric_attributes,
|
||||
)
|
||||
from data_layer.food_visibility import attach_visibility
|
||||
from data_layer.nutrition_items import profiles_dates_using_food, schedule_daily_nutrient_rebuild
|
||||
|
||||
name = (body.name_de or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "Name fehlt")
|
||||
serving = body.serving_g if body.serving_g and body.serving_g > 0 else None
|
||||
values = macros_and_attributes_to_values(body.macros_per_100g, body.attributes, serving)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name_de, name_en, catalog_kind, owner_profile_id
|
||||
FROM food_catalog
|
||||
WHERE id = %s AND catalog_kind IN ('manual_admin', 'manual_user') AND is_active = true
|
||||
""",
|
||||
(food_id,),
|
||||
)
|
||||
food = cur.fetchone()
|
||||
if not food:
|
||||
raise HTTPException(404, "Manuelles Lebensmittel nicht gefunden")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE food_catalog
|
||||
SET name_de = %s, name_en = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
RETURNING id, name_de, name_en, catalog_kind, owner_profile_id
|
||||
""",
|
||||
(name, body.name_en, food_id),
|
||||
)
|
||||
updated = dict(cur.fetchone())
|
||||
replace_numeric_attributes(cur, food_id, values)
|
||||
pairs = profiles_dates_using_food(cur, food_id)
|
||||
payload = attach_visibility(editor_payload_for_food(cur, updated, editable=True))
|
||||
from data_layer.food_suggest import invalidate_suggest_index
|
||||
invalidate_suggest_index()
|
||||
by_profile = defaultdict(list)
|
||||
for pid, day in pairs:
|
||||
by_profile[pid].append(day)
|
||||
for pid, dates in by_profile.items():
|
||||
schedule_daily_nutrient_rebuild(pid, dates, f"Zentrales Lebensmittel {name}")
|
||||
payload["rebuild_pending"] = len(pairs)
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/attributes")
|
||||
def admin_list_attributes(session: dict = Depends(require_admin)):
|
||||
from bls.attribute_labels import annotate_attribute, sort_attributes
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
|
|
@ -230,10 +309,18 @@ def admin_list_attributes(session: dict = Depends(require_admin)):
|
|||
SELECT id, attr_key, name_de, name_en, unit, category, data_type, origin, sort_order
|
||||
FROM food_attributes
|
||||
WHERE is_active = true
|
||||
ORDER BY origin, sort_order, attr_key
|
||||
"""
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
return sort_attributes([annotate_attribute(r2d(r)) for r in cur.fetchall()])
|
||||
|
||||
|
||||
@router.post("/attributes/fill-labels")
|
||||
def admin_fill_attribute_labels(session: dict = Depends(require_admin)):
|
||||
from bls.attribute_labels import fill_placeholder_labels
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
return fill_placeholder_labels(cur)
|
||||
|
||||
|
||||
@router.post("/attributes")
|
||||
|
|
@ -263,6 +350,50 @@ def admin_create_attribute(body: AttributeCreate, session: dict = Depends(requir
|
|||
return r2d(cur.fetchone())
|
||||
|
||||
|
||||
@router.put("/attributes/{attr_id}")
|
||||
def admin_update_attribute(attr_id: int, body: AttributeUpdate, session: dict = Depends(require_admin)):
|
||||
fields = []
|
||||
params: list = []
|
||||
if body.name_de is not None:
|
||||
name = body.name_de.strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "Name fehlt")
|
||||
fields.append("name_de = %s")
|
||||
params.append(name)
|
||||
if body.name_en is not None:
|
||||
fields.append("name_en = %s")
|
||||
params.append(body.name_en.strip() or None)
|
||||
if body.unit is not None:
|
||||
fields.append("unit = %s")
|
||||
params.append(body.unit.strip() or None)
|
||||
if body.category is not None:
|
||||
fields.append("category = %s")
|
||||
params.append(body.category.strip() or None)
|
||||
if body.sort_order is not None:
|
||||
fields.append("sort_order = %s")
|
||||
params.append(body.sort_order)
|
||||
if not fields:
|
||||
raise HTTPException(400, "Keine Änderung")
|
||||
fields.append("updated_at = NOW()")
|
||||
params.append(attr_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE food_attributes
|
||||
SET {', '.join(fields)}
|
||||
WHERE id = %s AND is_active = true
|
||||
RETURNING id, attr_key, name_de, name_en, unit, category, data_type, origin, sort_order
|
||||
""",
|
||||
params,
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Stoff nicht gefunden")
|
||||
from bls.attribute_labels import annotate_attribute
|
||||
return annotate_attribute(r2d(row))
|
||||
|
||||
|
||||
def _write_manual_macros(cur, food_id: str, macros: dict | None) -> None:
|
||||
from data_layer.food_attributes import macros_and_attributes_to_values, write_numeric_attributes
|
||||
write_numeric_attributes(cur, food_id, macros_and_attributes_to_values(macros, None))
|
||||
|
|
|
|||
|
|
@ -52,7 +52,8 @@ def list_food_mappings(
|
|||
q += " WHERE " + " AND ".join(conds)
|
||||
q += " ORDER BY m.source_name_normalized"
|
||||
cur.execute(q, params)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
from data_layer.food_visibility import attach_visibility
|
||||
return [attach_visibility(r2d(r)) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.get("/stats/coverage")
|
||||
|
|
@ -77,9 +78,13 @@ def mapping_coverage(session: dict = Depends(require_admin)):
|
|||
def create_food_mapping(body: FoodMappingCreate, session: dict = Depends(require_admin)):
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT id FROM food_catalog WHERE id = %s", (body.food_id,))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(404, "Lebensmittel nicht gefunden")
|
||||
from data_layer.food_visibility import require_mapping_target
|
||||
try:
|
||||
require_mapping_target(cur, body.food_id, body.profile_id or None)
|
||||
except LookupError as e:
|
||||
raise HTTPException(404, str(e)) from e
|
||||
except PermissionError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
mid = upsert_food_mapping(
|
||||
cur,
|
||||
source_name_raw=body.source_name,
|
||||
|
|
@ -114,9 +119,17 @@ def update_food_mapping(mapping_id: int, body: FoodMappingUpdate, session: dict
|
|||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Mapping nicht gefunden")
|
||||
cur.execute("SELECT id FROM food_catalog WHERE id = %s AND is_active = true", (body.food_id,))
|
||||
if not cur.fetchone():
|
||||
cur.execute("SELECT id, catalog_kind, owner_profile_id, is_active FROM food_catalog WHERE id = %s AND is_active = true", (body.food_id,))
|
||||
food = cur.fetchone()
|
||||
if not food:
|
||||
raise HTTPException(404, "Lebensmittel nicht gefunden")
|
||||
from data_layer.food_visibility import require_mapping_target
|
||||
try:
|
||||
require_mapping_target(cur, body.food_id, row.get("profile_id"))
|
||||
except LookupError as e:
|
||||
raise HTTPException(404, str(e)) from e
|
||||
except PermissionError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE food_name_mappings
|
||||
|
|
|
|||
|
|
@ -52,6 +52,12 @@ class SuggestBatchBody(BaseModel):
|
|||
limit: int = 3
|
||||
|
||||
|
||||
class UserAttributeCreate(BaseModel):
|
||||
name_de: str
|
||||
unit: str = "g"
|
||||
attr_key: Optional[str] = None
|
||||
|
||||
|
||||
def _pid(session: dict, x_profile_id: Optional[str] = None) -> str:
|
||||
return x_profile_id or session["profile_id"]
|
||||
|
||||
|
|
@ -89,23 +95,41 @@ def list_food_units(session: dict = Depends(require_auth)):
|
|||
def search_foods(
|
||||
q: str = "",
|
||||
limit: int = 20,
|
||||
scope: str = "visible",
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
pid = session["profile_id"]
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
return suggest_catalog_foods_ranked(cur, q, pid, limit=min(max(limit, 1), 50))
|
||||
hits = suggest_catalog_foods_ranked(cur, q, pid, limit=min(max(limit, 1), 50))
|
||||
if scope == "global":
|
||||
hits = [h for h in hits if h.get("visibility") != "personal"]
|
||||
return hits
|
||||
|
||||
|
||||
@router.get("/attributes")
|
||||
def list_food_attributes(
|
||||
q: str = "",
|
||||
keys: str = "",
|
||||
limit: int = 40,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
from data_layer.food_attributes import list_numeric_attributes
|
||||
key_list = [p.strip() for p in (keys or "").split(",") if p.strip()]
|
||||
with get_db() as conn:
|
||||
return list_numeric_attributes(get_cursor(conn), q, limit=limit)
|
||||
return list_numeric_attributes(get_cursor(conn), q, limit=limit, keys=key_list or None)
|
||||
|
||||
|
||||
@router.post("/attributes")
|
||||
def create_food_attribute(body: UserAttributeCreate, session: dict = Depends(require_auth)):
|
||||
from data_layer.food_attributes import create_extension_attribute
|
||||
try:
|
||||
with get_db() as conn:
|
||||
return create_extension_attribute(
|
||||
get_cursor(conn), body.name_de, body.unit, body.attr_key
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/foods/suggest-batch")
|
||||
|
|
@ -152,6 +176,122 @@ def create_user_food(
|
|||
return food
|
||||
|
||||
|
||||
@router.get("/foods/mine")
|
||||
def list_my_manual_foods(session: dict = Depends(require_auth)):
|
||||
pid = session["profile_id"]
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name_de, catalog_kind, updated_at
|
||||
FROM food_catalog
|
||||
WHERE owner_profile_id = %s AND catalog_kind = 'manual_user' AND is_active = true
|
||||
ORDER BY name_de
|
||||
""",
|
||||
(pid,),
|
||||
)
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
from data_layer.food_visibility import attach_visibility
|
||||
for row in rows:
|
||||
row["id"] = str(row["id"])
|
||||
attach_visibility(row)
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/foods/shared")
|
||||
def list_shared_manual_foods(session: dict = Depends(require_auth)):
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name_de, catalog_kind, updated_at
|
||||
FROM food_catalog
|
||||
WHERE catalog_kind = 'manual_admin' AND owner_profile_id IS NULL AND is_active = true
|
||||
ORDER BY name_de
|
||||
"""
|
||||
)
|
||||
from data_layer.food_visibility import attach_visibility
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
for row in rows:
|
||||
row["id"] = str(row["id"])
|
||||
attach_visibility(row)
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/foods/{food_id}")
|
||||
def get_food_detail(food_id: str, session: dict = Depends(require_auth)):
|
||||
from data_layer.food_attributes import editor_payload_for_food
|
||||
|
||||
pid = session["profile_id"]
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name_de, name_en, catalog_kind, owner_profile_id
|
||||
FROM food_catalog
|
||||
WHERE id = %s AND is_active = true
|
||||
AND (owner_profile_id IS NULL OR owner_profile_id = %s)
|
||||
""",
|
||||
(food_id, pid),
|
||||
)
|
||||
food = cur.fetchone()
|
||||
if not food:
|
||||
raise HTTPException(404, "Lebensmittel nicht gefunden")
|
||||
food = dict(food)
|
||||
from data_layer.food_visibility import attach_visibility, can_edit_food
|
||||
editable = can_edit_food(food, pid, is_admin=session.get("role") == "admin")
|
||||
return attach_visibility(editor_payload_for_food(cur, food, editable=editable))
|
||||
|
||||
|
||||
@router.put("/foods/manual/{food_id}")
|
||||
def update_user_food(food_id: str, body: UserFoodCreate, session: dict = Depends(require_auth)):
|
||||
from data_layer.food_attributes import (
|
||||
editor_payload_for_food,
|
||||
macros_and_attributes_to_values,
|
||||
replace_numeric_attributes,
|
||||
)
|
||||
from data_layer.nutrition_items import dates_using_food
|
||||
|
||||
pid = session["profile_id"]
|
||||
name = (body.name_de or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "Name fehlt")
|
||||
serving = body.serving_g if body.serving_g and body.serving_g > 0 else None
|
||||
values = macros_and_attributes_to_values(body.macros_per_100g, body.attributes, serving)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name_de, name_en, catalog_kind, owner_profile_id
|
||||
FROM food_catalog
|
||||
WHERE id = %s AND catalog_kind = 'manual_user' AND owner_profile_id = %s AND is_active = true
|
||||
""",
|
||||
(food_id, pid),
|
||||
)
|
||||
food = cur.fetchone()
|
||||
if not food:
|
||||
raise HTTPException(404, "Eigenes Lebensmittel nicht gefunden")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE food_catalog
|
||||
SET name_de = %s, name_en = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
RETURNING id, name_de, name_en, catalog_kind, owner_profile_id
|
||||
""",
|
||||
(name, body.name_en, food_id),
|
||||
)
|
||||
updated = dict(cur.fetchone())
|
||||
replace_numeric_attributes(cur, food_id, values)
|
||||
dates = dates_using_food(cur, pid, food_id)
|
||||
payload = editor_payload_for_food(cur, updated, editable=True)
|
||||
from data_layer.food_visibility import attach_visibility
|
||||
payload = attach_visibility(payload)
|
||||
invalidate_suggest_index(pid)
|
||||
_schedule_rebuild(pid, dates, f"Lebensmittel {name}")
|
||||
payload["rebuild_pending"] = len(dates)
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/mappings")
|
||||
def list_my_mappings(
|
||||
x_profile_id: Optional[str] = Header(default=None),
|
||||
|
|
@ -172,7 +312,8 @@ def list_my_mappings(
|
|||
""",
|
||||
(pid,),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
from data_layer.food_visibility import attach_visibility
|
||||
return [attach_visibility(r2d(r)) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.post("/mappings")
|
||||
|
|
@ -184,27 +325,26 @@ def upsert_my_mapping(
|
|||
pid = _pid(session, x_profile_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name_de, bls_code, catalog_kind FROM food_catalog
|
||||
WHERE id = %s AND is_active = true
|
||||
AND (owner_profile_id IS NULL OR owner_profile_id = %s)
|
||||
""",
|
||||
(body.food_id, pid),
|
||||
)
|
||||
food = cur.fetchone()
|
||||
if not food:
|
||||
raise HTTPException(404, "Lebensmittel nicht gefunden")
|
||||
mid = upsert_food_mapping(
|
||||
cur,
|
||||
source_name_raw=body.source_name,
|
||||
food_id=body.food_id,
|
||||
profile_id=pid,
|
||||
source="bulk",
|
||||
source_system=body.source_system,
|
||||
grams_per_unit=body.grams_per_unit,
|
||||
source_unit=body.source_unit,
|
||||
)
|
||||
from data_layer.food_visibility import require_visible_food, visibility_of
|
||||
try:
|
||||
food = require_visible_food(cur, body.food_id, pid)
|
||||
except LookupError as e:
|
||||
raise HTTPException(404, str(e)) from e
|
||||
try:
|
||||
mid = upsert_food_mapping(
|
||||
cur,
|
||||
source_name_raw=body.source_name,
|
||||
food_id=body.food_id,
|
||||
profile_id=pid,
|
||||
source="bulk",
|
||||
source_system=body.source_system,
|
||||
grams_per_unit=body.grams_per_unit,
|
||||
source_unit=body.source_unit,
|
||||
)
|
||||
except PermissionError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
except LookupError as e:
|
||||
raise HTTPException(404, str(e)) from e
|
||||
norm = normalize_food_name(body.source_name)
|
||||
n = apply_mapping_to_items(cur, pid, norm, body.food_id, mid)
|
||||
apply_quantities_to_items(cur, pid, norm, body.grams_per_unit)
|
||||
|
|
@ -218,6 +358,7 @@ def upsert_my_mapping(
|
|||
"food_name_de": food["name_de"],
|
||||
"bls_code": food.get("bls_code"),
|
||||
"catalog_kind": food.get("catalog_kind"),
|
||||
"visibility": visibility_of(food),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
57
backend/tests/test_attribute_labels.py
Normal file
57
backend/tests/test_attribute_labels.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from bls.attribute_labels import (
|
||||
annotate_attribute,
|
||||
apply_official_name,
|
||||
fill_placeholder_labels,
|
||||
label_is_placeholder,
|
||||
official_label,
|
||||
resolve_group,
|
||||
sort_attributes,
|
||||
)
|
||||
|
||||
|
||||
def test_official_macro_labels():
|
||||
assert official_label("ENERCC")["name_de"].startswith("Energie")
|
||||
assert official_label("cho")["name_de"].startswith("Kohlenhydrate")
|
||||
assert label_is_placeholder("ENERCC", "ENERCC")
|
||||
assert label_is_placeholder("", "FAT")
|
||||
assert not label_is_placeholder("Fett", "FAT")
|
||||
assert apply_official_name("FAT", "FAT") == "Fett"
|
||||
assert apply_official_name("FAT", "Fett gesamt") == "Fett gesamt"
|
||||
|
||||
|
||||
def test_fill_placeholder_labels_skips_custom_names():
|
||||
class _Cur:
|
||||
def __init__(self):
|
||||
self.rows = [
|
||||
{"id": 1, "attr_key": "FAT", "name_de": "FAT", "unit": None, "category": None},
|
||||
{"id": 2, "attr_key": "EPA", "name_de": "Mein EPA", "unit": "g", "category": None},
|
||||
]
|
||||
self.updated = []
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
self.last = (sql, params)
|
||||
if "UPDATE" in sql:
|
||||
self.updated.append(params)
|
||||
|
||||
def fetchall(self):
|
||||
return self.rows
|
||||
|
||||
cur = _Cur()
|
||||
stats = fill_placeholder_labels(cur)
|
||||
assert stats["updated"] == 2
|
||||
assert cur.updated[0][0] == "Fett"
|
||||
assert cur.updated[0][3] == 1
|
||||
assert cur.updated[1][0] == "Mein EPA"
|
||||
assert cur.updated[1][2] == "fatty_acids"
|
||||
|
||||
|
||||
def test_group_and_sort_macros_first():
|
||||
assert resolve_group("ENERCC")[0] == "energy"
|
||||
assert resolve_group("NA", None, None)[1] == "Mineralstoffe"
|
||||
assert resolve_group("XYZ", None, "extension")[0] == "extension"
|
||||
rows = sort_attributes([
|
||||
annotate_attribute({"attr_key": "NA", "name_de": "Natrium", "sort_order": 2, "origin": "official_bls"}),
|
||||
annotate_attribute({"attr_key": "FAT", "name_de": "Fett", "sort_order": 9, "origin": "official_bls"}),
|
||||
annotate_attribute({"attr_key": "ENERCC", "name_de": "Energie", "sort_order": 1, "origin": "official_bls"}),
|
||||
])
|
||||
assert [r["attr_key"] for r in rows] == ["ENERCC", "FAT", "NA"]
|
||||
|
|
@ -30,6 +30,18 @@ def test_parse_components_dynamic():
|
|||
assert "NA" in keys
|
||||
|
||||
|
||||
def test_parse_components_official_bezeichnung_and_empty_name():
|
||||
data = _xlsx([
|
||||
["Component code", "Bezeichnung", "English", "Einheit"],
|
||||
["PROT625", "", "Protein", "g/100g"],
|
||||
["FAT", "Fett", "Fat", "g/100g"],
|
||||
])
|
||||
attrs = parse_components_xlsx(data)
|
||||
by_key = {a["attr_key"]: a for a in attrs}
|
||||
assert by_key["PROT625"]["name_de"] == "Protein (N×6,25)"
|
||||
assert by_key["FAT"]["name_de"] == "Fett"
|
||||
|
||||
|
||||
def test_parse_foods_keeps_bls_code():
|
||||
data = _xlsx([
|
||||
["BLS Code", "Name", "Food name", "ENERCC Energie [kcal/100g]", "ENERCC Herkunft", "ENERCC Referenz"],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
from data_layer.food_attributes import macros_and_attributes_to_values, scale_to_per_100g
|
||||
from data_layer.food_attributes import (
|
||||
create_extension_attribute,
|
||||
editor_payload_for_food,
|
||||
macros_and_attributes_to_values,
|
||||
normalize_attr_key,
|
||||
normalize_attr_unit,
|
||||
scale_to_per_100g,
|
||||
)
|
||||
|
||||
|
||||
def test_scale_serving_to_per_100g():
|
||||
|
|
@ -7,6 +14,28 @@ def test_scale_serving_to_per_100g():
|
|||
assert scale_to_per_100g(10, None) == 10
|
||||
|
||||
|
||||
def test_attr_key_and_unit_normalize():
|
||||
assert normalize_attr_key("Vitamin D3") == "VITAMIN_D3"
|
||||
assert normalize_attr_unit("ug") == "µg"
|
||||
assert normalize_attr_unit("mcg") == "µg"
|
||||
|
||||
|
||||
def test_create_extension_returns_existing():
|
||||
class _Cur:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
self.calls += 1
|
||||
self._row = {"id": 1, "attr_key": "VITAMIN_D3", "name_de": "Vitamin D3", "name_en": None, "unit": "µg", "category": "extension", "origin": "extension"} if self.calls == 1 else None
|
||||
|
||||
def fetchone(self):
|
||||
return self._row
|
||||
|
||||
row = create_extension_attribute(_Cur(), "Vitamin D3", "µg")
|
||||
assert row["attr_key"] == "VITAMIN_D3"
|
||||
|
||||
|
||||
def test_norsan_label_epa_converts():
|
||||
"""Etikett: 1100 mg EPA = 1.1 g in 8 g Portion → g/100 g."""
|
||||
vals = macros_and_attributes_to_values(
|
||||
|
|
@ -17,3 +46,41 @@ def test_norsan_label_epa_converts():
|
|||
assert abs(vals["FAT"] - 100) < 0.01
|
||||
assert abs(vals["EPA"] - 13.75) < 0.01
|
||||
assert abs(vals["ENERCC"] - 900) < 0.01
|
||||
|
||||
|
||||
def test_edit_payload_does_not_rescale_at_100g():
|
||||
vals = macros_and_attributes_to_values(
|
||||
{"kcal": 900, "protein_g": 0, "fat_g": 100, "carbs_g": 0},
|
||||
{"EPA": 13.75},
|
||||
serving_g=100,
|
||||
)
|
||||
assert vals["FAT"] == 100
|
||||
assert vals["EPA"] == 13.75
|
||||
assert vals["ENERCC"] == 900
|
||||
|
||||
|
||||
def test_editor_payload_splits_macros_and_extras():
|
||||
class _Cur:
|
||||
def execute(self, sql, params=None):
|
||||
pass
|
||||
|
||||
def fetchall(self):
|
||||
return [
|
||||
{"attr_key": "ENERCC", "name_de": "Energie", "name_en": None, "unit": "kcal", "category": "energy", "origin": "bls", "value_num": 900},
|
||||
{"attr_key": "FAT", "name_de": "Fett", "name_en": None, "unit": "g", "category": "macro", "origin": "bls", "value_num": 100},
|
||||
{"attr_key": "PROT625", "name_de": "Protein", "name_en": None, "unit": "g", "category": "macro", "origin": "bls", "value_num": 0},
|
||||
{"attr_key": "CHO", "name_de": "KH", "name_en": None, "unit": "g", "category": "macro", "origin": "bls", "value_num": 0},
|
||||
{"attr_key": "EPA", "name_de": "EPA", "name_en": None, "unit": "g", "category": "extension", "origin": "extension", "value_num": 13.75},
|
||||
]
|
||||
|
||||
payload = editor_payload_for_food(
|
||||
_Cur(),
|
||||
{"id": "abc", "name_de": "Omega-3", "name_en": None, "catalog_kind": "manual_user"},
|
||||
editable=True,
|
||||
)
|
||||
assert payload["editable"] is True
|
||||
assert payload["serving_g"] == 100
|
||||
assert payload["macros_per_100g"]["kcal"] == 900
|
||||
assert payload["macros_per_100g"]["fat_g"] == 100
|
||||
assert payload["attributes"][0]["attr"]["attr_key"] == "EPA"
|
||||
assert payload["attributes"][0]["value"] == 13.75
|
||||
|
|
|
|||
|
|
@ -105,3 +105,11 @@ def test_joghurt_10_ranks_above_35():
|
|||
packed = suggest_for_name(index, query, limit=3)
|
||||
assert packed["suggestions"][0]["name_de"] == full["name_de"], query
|
||||
assert packed["suggestions"][0]["score"] > score_name_match(query, light["name_de"])
|
||||
|
||||
|
||||
def test_personal_food_ranks_above_bls_on_tie():
|
||||
bls = {"id": "bls", "name_de": "Omega-3 Öl", "name_en": "", "bls_code": "X", "catalog_kind": "official_bls"}
|
||||
mine = {"id": "me", "name_de": "Omega-3 Öl", "name_en": "", "bls_code": None, "catalog_kind": "manual_user", "owner_profile_id": "u1"}
|
||||
packed = suggest_for_name(_index(bls, mine), "Omega-3 Öl", limit=2)
|
||||
assert packed["suggestions"][0]["id"] == "me"
|
||||
assert packed["suggestions"][0]["visibility"] == "personal"
|
||||
|
|
|
|||
108
backend/tests/test_food_visibility.py
Normal file
108
backend/tests/test_food_visibility.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
from data_layer.food_visibility import (
|
||||
attach_visibility,
|
||||
can_edit_food,
|
||||
is_global_mapping_target,
|
||||
is_visible_to,
|
||||
require_mapping_target,
|
||||
same_id,
|
||||
visibility_of,
|
||||
visibility_rank,
|
||||
)
|
||||
|
||||
|
||||
def test_can_edit_central_only_as_admin():
|
||||
central = {"catalog_kind": "manual_admin", "owner_profile_id": None, "is_active": True}
|
||||
personal = {"catalog_kind": "manual_user", "owner_profile_id": "alice", "is_active": True}
|
||||
bls = {"catalog_kind": "official_bls", "owner_profile_id": None, "is_active": True}
|
||||
assert can_edit_food(central, "alice", is_admin=False) is False
|
||||
assert can_edit_food(central, "alice", is_admin=True) is True
|
||||
assert can_edit_food(personal, "alice", is_admin=False) is True
|
||||
assert can_edit_food(personal, "bob", is_admin=False) is False
|
||||
assert can_edit_food(personal, "bob", is_admin=True) is True
|
||||
assert can_edit_food(bls, "alice", is_admin=True) is False
|
||||
|
||||
|
||||
def test_same_id_ignores_dashes_and_case():
|
||||
assert same_id("AaBbCcDd-0000-0000-0000-000000000001", "aabbccdd000000000000000000000001")
|
||||
|
||||
|
||||
def test_visibility_scopes():
|
||||
assert visibility_of({"catalog_kind": "official_bls"}) == "bls"
|
||||
assert visibility_of({"catalog_kind": "manual_admin", "owner_profile_id": None}) == "global"
|
||||
assert visibility_of({"catalog_kind": "manual_user", "owner_profile_id": "u1"}) == "personal"
|
||||
assert visibility_rank({"catalog_kind": "manual_user"}) < visibility_rank({"catalog_kind": "manual_admin"})
|
||||
assert visibility_rank({"catalog_kind": "manual_admin"}) < visibility_rank({"catalog_kind": "official_bls"})
|
||||
|
||||
|
||||
def test_personal_food_not_visible_to_others():
|
||||
food = {"catalog_kind": "manual_user", "owner_profile_id": "alice", "is_active": True}
|
||||
assert is_visible_to(food, "alice") is True
|
||||
assert is_visible_to(food, "bob") is False
|
||||
assert is_global_mapping_target(food) is False
|
||||
|
||||
|
||||
def test_admin_and_bls_visible_to_everyone():
|
||||
admin = {"catalog_kind": "manual_admin", "owner_profile_id": None, "is_active": True}
|
||||
bls = {"catalog_kind": "official_bls", "owner_profile_id": None, "is_active": True}
|
||||
assert is_visible_to(admin, "bob") is True
|
||||
assert is_visible_to(bls, None) is True
|
||||
assert is_global_mapping_target(admin) is True
|
||||
assert is_global_mapping_target(bls) is True
|
||||
|
||||
|
||||
def test_attach_visibility():
|
||||
row = attach_visibility({"catalog_kind": "manual_admin"})
|
||||
assert row["visibility"] == "global"
|
||||
|
||||
|
||||
def test_require_global_mapping_rejects_personal():
|
||||
class _Cur:
|
||||
def execute(self, sql, params=None):
|
||||
self._row = {
|
||||
"id": params[0],
|
||||
"name_de": "Öl",
|
||||
"catalog_kind": "manual_user",
|
||||
"owner_profile_id": "alice",
|
||||
"is_active": True,
|
||||
"bls_code": None,
|
||||
}
|
||||
|
||||
def fetchone(self):
|
||||
return self._row
|
||||
|
||||
try:
|
||||
require_mapping_target(_Cur(), "fid", None)
|
||||
assert False, "expected PermissionError"
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
|
||||
def test_require_user_mapping_allows_own_and_global():
|
||||
class _Cur:
|
||||
def __init__(self, food):
|
||||
self.food = food
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
pass
|
||||
|
||||
def fetchone(self):
|
||||
return self.food
|
||||
|
||||
own = require_mapping_target(
|
||||
_Cur({
|
||||
"id": "1", "name_de": "X", "catalog_kind": "manual_user",
|
||||
"owner_profile_id": "alice", "is_active": True, "bls_code": None,
|
||||
}),
|
||||
"1",
|
||||
"alice",
|
||||
)
|
||||
assert own["catalog_kind"] == "manual_user"
|
||||
shared = require_mapping_target(
|
||||
_Cur({
|
||||
"id": "2", "name_de": "Y", "catalog_kind": "manual_admin",
|
||||
"owner_profile_id": None, "is_active": True, "bls_code": None,
|
||||
}),
|
||||
"2",
|
||||
"alice",
|
||||
)
|
||||
assert shared["catalog_kind"] == "manual_admin"
|
||||
|
|
@ -8,8 +8,8 @@ Semantic Versioning: MAJOR.MINOR.PATCH
|
|||
"""
|
||||
|
||||
APP_VERSION = "0.9v"
|
||||
BUILD_DATE = "2026-09-12"
|
||||
DB_SCHEMA_VERSION = "20260912e" # 068 nutrition unmapped/recipe indexes
|
||||
BUILD_DATE = "2026-09-14"
|
||||
DB_SCHEMA_VERSION = "20260914a" # 069 food catalog visibility owner check
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"auth": "1.2.0",
|
||||
|
|
@ -20,9 +20,10 @@ MODULE_VERSIONS = {
|
|||
"circumference": "1.0.1",
|
||||
"caliper": "1.0.1",
|
||||
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
|
||||
"nutrition": "1.3.5", # Listen-Dialog: Mapping an der Zutat; Suche Listen/eigene vor BLS
|
||||
"nutrition": "1.3.7", # Sichtbarkeit persönlich vs. zentral im Mapping
|
||||
"tandoor": "1.0.1", # Router-Import ohne future-annotations (Backend startet)
|
||||
"bls": "1.0.5", # GET /bls/units
|
||||
"bls": "1.0.13", # Stoffe gruppiert und sortiert (BLS-Nährstoffgruppen)
|
||||
|
||||
"photos": "1.0.0",
|
||||
"insights": "1.3.0",
|
||||
"prompts": "1.1.0",
|
||||
|
|
@ -55,6 +56,9 @@ CHANGELOG = [
|
|||
"Zuordnen: Zeitraum 14 Tage / 4 Wochen / 90 Tage / Alle — zuerst aktuelle Tagebuchnamen",
|
||||
"Katalogsuche: Fettgehalt (9,5 / 10 %) bleibt erhalten, Joghurt 10% vor >3,5%",
|
||||
"Eigenes Lebensmittel: beliebige Katalog-Stoffe (EPA/DHA/…) plus Portionsumrechnung auf 100 g",
|
||||
"Eigene Lebensmittel nachträglich bearbeiten (Name, Makros, Stoffe); BLS bleibt fest",
|
||||
"Reiter Lebensmittel (Nutzer) und Admin-Katalog Manuell: Liste + Suche",
|
||||
"Sichtbarkeit: persönliche Foods nur Owner, zentrale Admin-Foods für alle; Mapping trennt global vs. Nutzer",
|
||||
"Zuordnen: Mapping-Export robust; Rezepte anlegen/bearbeiten; Admin-Mapping direkt ändern",
|
||||
"Rezept-Dialog: Zutaten aus Zuordnungen und Katalog suchen; Tandoor später",
|
||||
"Rezeptzutaten: Menge + Einheit (EL/TL/Prise/Stück); Umrechnung auf Gramm",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernende
|
|||
- Zuordnungen und Listen als JSON zwischen Instanzen übertragen
|
||||
- Eigener Katalogeintrag im Suchpopup; Gramm pro Stück/EL/… am Mapping
|
||||
- Manueller Eintrag: beliebige `num_per_100g`-Stoffe (EPA/DHA/…) und Portionsumrechnung
|
||||
- Eigene Lebensmittel nachträglich bearbeiten (Tab Ernährung → Lebensmittel; persönlich vs. zentral; BLS unveränderlich)
|
||||
- Listen-Dialog mit Zutatensuche (Mappings + Katalog); Mitai = Roh-Kombination
|
||||
- Listenzutaten: Menge + Einheit, Umrechnung auf Gramm (EL/TL/Prise/Stück)
|
||||
- Tandoor: persönlicher URL+Token in Einstellungen, Verbindungstest (kein Import)
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
|
|||
|
||||
/* Tabs */
|
||||
.tabs { display: flex; gap: 4px; background: var(--surface2); border-radius: 10px; padding: 3px; margin-bottom: 16px; }
|
||||
.tab { flex: 1; text-align: center; padding: 7px; border-radius: 8px; font-size: 13px; font-weight: 500; color: var(--text3); cursor: pointer; border: none; background: transparent; font-family: var(--font); }
|
||||
.tab { flex: 1; text-align: center; padding: 7px; border-radius: 8px; font-size: 13px; font-weight: 500; color: var(--text3); cursor: pointer; border: none; background: transparent; font-family: var(--font); white-space: nowrap; }
|
||||
.tab.active { background: var(--surface); color: var(--text1); box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||
|
||||
/* Section */
|
||||
|
|
|
|||
164
frontend/src/components/FoodEditDialog.jsx
Normal file
164
frontend/src/components/FoodEditDialog.jsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
|
||||
|
||||
function numStr(v) {
|
||||
if (v == null || v === '') return '0'
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? String(n) : '0'
|
||||
}
|
||||
|
||||
export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode }) {
|
||||
const { isAdmin: sessionAdmin } = useAuth() || {}
|
||||
const isCreate = !foodId
|
||||
const createAsAdmin = mode === 'admin'
|
||||
const [name, setName] = useState('')
|
||||
const [macros, setMacros] = useState({ kcal: '0', protein_g: '0', fat_g: '0', carbs_g: '0' })
|
||||
const [extras, setExtras] = useState([])
|
||||
const [servingG, setServingG] = useState('100')
|
||||
const [catalogKind, setCatalogKind] = useState(null)
|
||||
const [editable, setEditable] = useState(false)
|
||||
const [loading, setLoading] = useState(!isCreate)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const saveAsAdmin = isCreate
|
||||
? createAsAdmin
|
||||
: Boolean(sessionAdmin && catalogKind && catalogKind !== 'official_bls' && (
|
||||
catalogKind === 'manual_admin' || mode === 'admin'
|
||||
))
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreate) {
|
||||
setEditable(true)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
let alive = true
|
||||
const load = (sessionAdmin || mode === 'admin')
|
||||
? api.adminBlsFoodDetail(foodId, true).catch(() => api.getBlsFood(foodId))
|
||||
: api.getBlsFood(foodId)
|
||||
load.then((d) => {
|
||||
if (!alive) return
|
||||
setName(d.name_de || '')
|
||||
setCatalogKind(d.catalog_kind || null)
|
||||
const m = d.macros_per_100g || {}
|
||||
setMacros({
|
||||
kcal: numStr(m.kcal),
|
||||
protein_g: numStr(m.protein_g),
|
||||
fat_g: numStr(m.fat_g),
|
||||
carbs_g: numStr(m.carbs_g),
|
||||
})
|
||||
setExtras((d.attributes || []).filter((row) => row?.attr).map((row) => ({
|
||||
attr: row.attr,
|
||||
value: numStr(row.value),
|
||||
inputUnit: row.attr?.unit || 'g',
|
||||
})))
|
||||
setServingG(numStr(d.serving_g || 100) === '0' ? '100' : numStr(d.serving_g || 100))
|
||||
const canEdit = Boolean(d.editable) || (
|
||||
sessionAdmin && d.catalog_kind && d.catalog_kind !== 'official_bls'
|
||||
)
|
||||
setEditable(canEdit)
|
||||
setLoading(false)
|
||||
}).catch((e) => {
|
||||
if (!alive) return
|
||||
setError(e.message)
|
||||
setLoading(false)
|
||||
})
|
||||
return () => { alive = false }
|
||||
}, [foodId, isCreate, sessionAdmin, mode])
|
||||
|
||||
const save = async () => {
|
||||
if (!name.trim()) {
|
||||
setError('Name fehlt')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const body = foodCreatePayload(name, macros, extras, servingG)
|
||||
let saved
|
||||
if (saveAsAdmin) {
|
||||
saved = isCreate ? await api.adminCreateManualFood(body) : await api.adminUpdateManualFood(foodId, body)
|
||||
} else {
|
||||
saved = isCreate ? await api.createUserFood(body) : await api.updateUserFood(foodId, body)
|
||||
}
|
||||
onSaved?.(saved)
|
||||
onClose?.()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const title = isCreate
|
||||
? (createAsAdmin ? 'Zentrales Lebensmittel anlegen' : 'Lebensmittel anlegen')
|
||||
: (catalogKind === 'manual_admin' ? 'Zentrales Lebensmittel bearbeiten' : 'Lebensmittel bearbeiten')
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: zIndex || 23000,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
width: '100%', maxWidth: 520, maxHeight: 'min(92vh, 800px)',
|
||||
background: 'var(--surface)', borderRadius: 16,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 16px', borderBottom: '1px solid var(--border)' }}>
|
||||
<h2 className="card-title" style={{ margin: 0, fontSize: 16 }}>{title}</h2>
|
||||
<button type="button" className="btn btn-secondary" disabled={saving} onClick={onClose}>Schließen</button>
|
||||
</div>
|
||||
<div style={{ overflowY: 'auto', padding: '12px 16px 16px', flex: 1 }}>
|
||||
{loading && <p style={{ fontSize: 13 }}>Lade…</p>}
|
||||
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
|
||||
{!loading && !editable && (
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)' }}>
|
||||
{catalogKind === 'manual_admin'
|
||||
? 'Zentrale Lebensmittel ändert nur ein Admin.'
|
||||
: 'Offizielle BLS-Einträge sind fest. Nur selbst angelegte Lebensmittel lassen sich ändern.'}
|
||||
</p>
|
||||
)}
|
||||
{!loading && editable && (
|
||||
<>
|
||||
<label className="form-label">Name</label>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: '100%', textAlign: 'left', marginBottom: 12 }}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<FoodNutrientFields
|
||||
macros={macros}
|
||||
setMacros={setMacros}
|
||||
extras={extras}
|
||||
setExtras={setExtras}
|
||||
servingG={servingG}
|
||||
setServingG={setServingG}
|
||||
valuesArePer100g={!isCreate}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{editable && (
|
||||
<div style={{ display: 'flex', gap: 8, padding: '12px 16px', borderTop: '1px solid var(--border)' }}>
|
||||
<button type="button" className="btn btn-secondary" disabled={saving} onClick={onClose}>Abbrechen</button>
|
||||
<button type="button" className="btn btn-primary" style={{ flex: 1 }} disabled={saving || loading} onClick={save}>
|
||||
{saving ? 'Speichere…' : (isCreate ? 'Anlegen' : 'Speichern')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +1,35 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
const MASS = { g: 1, mg: 0.001, 'µg': 0.000001, ug: 0.000001, mcg: 0.000001 }
|
||||
const QUICK_KEYS = ['EPA', 'DHA', 'DPA', 'OMEGA3']
|
||||
|
||||
function parseNum(raw) {
|
||||
const n = parseFloat(String(raw ?? '').replace(',', '.'))
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
function attrUnit(attr) {
|
||||
const u = (attr?.unit || 'g').trim()
|
||||
if (u === 'ug' || u === 'mcg') return 'µg'
|
||||
return u
|
||||
}
|
||||
|
||||
export function convertMass(value, fromUnit, toUnit) {
|
||||
const from = MASS[(fromUnit || 'g').toLowerCase()]
|
||||
const to = MASS[(toUnit || 'g').toLowerCase()]
|
||||
if (!from || !to) return value
|
||||
return value * (from / to)
|
||||
}
|
||||
|
||||
export function foodCreatePayload(name, macros, extras, servingG) {
|
||||
const attributes = {}
|
||||
for (const row of extras || []) {
|
||||
if (!row?.attr?.attr_key || row.value === '' || row.value == null) continue
|
||||
attributes[row.attr.attr_key] = parseNum(row.value)
|
||||
const n = parseNum(row.value)
|
||||
const target = attrUnit(row.attr)
|
||||
const entered = row.inputUnit || target
|
||||
attributes[row.attr.attr_key] = convertMass(n, entered, target)
|
||||
}
|
||||
const serving = parseNum(servingG)
|
||||
return {
|
||||
|
|
@ -26,13 +45,23 @@ export function foodCreatePayload(name, macros, extras, servingG) {
|
|||
}
|
||||
}
|
||||
|
||||
export default function FoodNutrientFields({ macros, setMacros, extras, setExtras, servingG, setServingG }) {
|
||||
export default function FoodNutrientFields({ macros, setMacros, extras, setExtras, servingG, setServingG, valuesArePer100g }) {
|
||||
const [q, setQ] = useState('')
|
||||
const [hits, setHits] = useState([])
|
||||
const [quick, setQuick] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newUnit, setNewUnit] = useState('g')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [createErr, setCreateErr] = useState(null)
|
||||
const timer = useRef(null)
|
||||
|
||||
useEffect(() => () => clearTimeout(timer.current), [])
|
||||
useEffect(() => {
|
||||
api.listFoodAttributes('', 20, QUICK_KEYS.join(',')).then((rows) => {
|
||||
setQuick(Array.isArray(rows) ? rows : [])
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const search = (term) => {
|
||||
clearTimeout(timer.current)
|
||||
|
|
@ -55,7 +84,9 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
|
|||
|
||||
const add = (attr) => {
|
||||
setExtras((list) => (
|
||||
list.some((r) => r.attr.attr_key === attr.attr_key) ? list : [...list, { attr, value: '' }]
|
||||
list.some((r) => r.attr.attr_key === attr.attr_key)
|
||||
? list
|
||||
: [...list, { attr, value: '', inputUnit: attrUnit(attr) }]
|
||||
))
|
||||
setQ('')
|
||||
setHits([])
|
||||
|
|
@ -65,10 +96,37 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
|
|||
setExtras((list) => list.map((r) => (r.attr.attr_key === key ? { ...r, value } : r)))
|
||||
}
|
||||
|
||||
const setInputUnit = (key, unit) => {
|
||||
setExtras((list) => list.map((r) => (r.attr.attr_key === key ? { ...r, inputUnit: unit } : r)))
|
||||
}
|
||||
|
||||
const createAttr = async () => {
|
||||
const name = (newName || q).trim()
|
||||
if (!name) {
|
||||
setCreateErr('Name des Stoffs fehlt')
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
setCreateErr(null)
|
||||
try {
|
||||
const attr = await api.createFoodAttribute({ name_de: name, unit: newUnit })
|
||||
add(attr)
|
||||
setNewName('')
|
||||
} catch (e) {
|
||||
setCreateErr(e.message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const unusedQuick = quick.filter((a) => !extras.some((r) => r.attr.attr_key === a.attr_key))
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 8px' }}>
|
||||
Zahlen in der Einheit des Stoffs. Bei Etikett „pro 8 ml / 8 g“ die Portionsgröße setzen — gespeichert wird immer pro 100 g.
|
||||
{valuesArePer100g
|
||||
? 'Geladen sind die gespeicherten Werte je 100 g. Portion nur ändern, wenn du vom Etikett neu einträgst (sonst 100 lassen).'
|
||||
: 'Etikettwerte so eintragen, wie sie dastehen. Portionsgröße setzen (5 ml Öl ≈ 5 g). Gespeichert wird immer pro 100 g. Ein Öl/Supplement ist ein Lebensmittel, keine Liste — Listen können keine Listen enthalten.'}
|
||||
</p>
|
||||
<label style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginBottom: 8 }}>
|
||||
Angabe gilt für{' '}
|
||||
|
|
@ -81,7 +139,7 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
|
|||
value={servingG}
|
||||
onChange={(e) => setServingG(e.target.value)}
|
||||
/>
|
||||
{' '}g (100 = pro 100 g)
|
||||
{' '}g (100 = pro 100 g; 5 ml Öl meist 5)
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
{[['kcal', 'kcal'], ['protein_g', 'Protein g'], ['fat_g', 'Fett g'], ['carbs_g', 'Kohlenhydrate g']].map(([key, label]) => (
|
||||
|
|
@ -99,11 +157,20 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
|
|||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ fontSize: 12, fontWeight: 600, margin: '12px 0 6px' }}>Weitere Stoffe (EPA, DHA, Ballaststoffe, …)</p>
|
||||
<p style={{ fontSize: 12, fontWeight: 600, margin: '12px 0 6px' }}>Weitere Stoffe</p>
|
||||
{unusedQuick.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
|
||||
{unusedQuick.map((a) => (
|
||||
<button key={a.attr_key} type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => add(a)}>
|
||||
{a.attr_key}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: '100%', textAlign: 'left' }}
|
||||
placeholder="Stoff suchen, z. B. EPA oder Omega"
|
||||
placeholder="Stoff suchen, z. B. EPA, Omega, Vitamin D"
|
||||
value={q}
|
||||
onChange={(e) => { setQ(e.target.value); search(e.target.value) }}
|
||||
/>
|
||||
|
|
@ -122,21 +189,72 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
|
|||
</span>
|
||||
</button>
|
||||
))}
|
||||
{extras.map((row) => (
|
||||
<label key={row.attr.attr_key} style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 8 }}>
|
||||
{row.attr.name_de}{row.attr.unit ? ` (${row.attr.unit})` : ''}
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.001"
|
||||
style={{ width: '100%', textAlign: 'left', marginTop: 4 }}
|
||||
value={row.value}
|
||||
onChange={(e) => setValue(row.attr.attr_key, e.target.value)}
|
||||
placeholder="Wert in der Einheit oben"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<div style={{ marginTop: 10, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
|
||||
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '0 0 6px' }}>Stoff nicht gefunden? Neu anlegen (wird für alle Lebensmittel nutzbar).</p>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: '100%', textAlign: 'left', marginBottom: 6 }}
|
||||
placeholder="Name, z. B. Vitamin D3"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<select className="form-input" style={{ width: 90, textAlign: 'left' }} value={newUnit} onChange={(e) => setNewUnit(e.target.value)}>
|
||||
<option value="g">g</option>
|
||||
<option value="mg">mg</option>
|
||||
<option value="µg">µg</option>
|
||||
</select>
|
||||
<button type="button" className="btn btn-secondary" disabled={creating} onClick={createAttr}>
|
||||
{creating ? '…' : 'Parameter anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
{createErr && <p style={{ color: 'var(--danger)', fontSize: 12, margin: '6px 0 0' }}>{createErr}</p>}
|
||||
</div>
|
||||
{extras.map((row) => {
|
||||
const target = attrUnit(row.attr)
|
||||
const entered = row.inputUnit || target
|
||||
const mass = MASS[target.toLowerCase()]
|
||||
return (
|
||||
<label key={row.attr.attr_key} style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 8 }}>
|
||||
{row.attr.name_de} (Katalog: {target})
|
||||
<span style={{ display: 'flex', gap: 6, marginTop: 4 }}>
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.001"
|
||||
style={{ flex: 1, textAlign: 'left' }}
|
||||
value={row.value}
|
||||
onChange={(e) => setValue(row.attr.attr_key, e.target.value)}
|
||||
placeholder="Wert vom Etikett"
|
||||
/>
|
||||
{mass ? (
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ width: 72, textAlign: 'left' }}
|
||||
value={entered}
|
||||
onChange={(e) => setInputUnit(row.attr.attr_key, e.target.value)}
|
||||
>
|
||||
<option value="g">g</option>
|
||||
<option value="mg">mg</option>
|
||||
<option value="µg">µg</option>
|
||||
</select>
|
||||
) : (
|
||||
<span style={{ alignSelf: 'center' }}>{target}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '6px 10px' }}
|
||||
onClick={() => setExtras((list) => list.filter((r) => r.attr.attr_key !== row.attr.attr_key))}
|
||||
aria-label="Stoff entfernen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import FoodSearchModal from './FoodSearchModal'
|
||||
import FoodEditDialog from './FoodEditDialog'
|
||||
import { foodVisibilityLabel } from '../utils/foodVisibility'
|
||||
|
||||
const FALLBACK_UNITS = [
|
||||
{ id: 'g', label: 'g', default_g: 1, needs_unit_map: false },
|
||||
|
|
@ -111,6 +114,7 @@ export default function FoodRecipeDialog({
|
|||
onSave,
|
||||
onClose,
|
||||
}) {
|
||||
const { isAdmin } = useAuth()
|
||||
const [name, setName] = useState(defaultName || '')
|
||||
const [portions, setPortions] = useState(defaultPortions != null ? String(defaultPortions) : '1')
|
||||
const [ings, setIngs] = useState(
|
||||
|
|
@ -128,6 +132,7 @@ export default function FoodRecipeDialog({
|
|||
const [knownNames, setKnownNames] = useState([])
|
||||
const [suggestions, setSuggestions] = useState({})
|
||||
const [mapFor, setMapFor] = useState(null)
|
||||
const [editFoodId, setEditFoodId] = useState(null)
|
||||
const nameRef = useRef(null)
|
||||
const qtyRefs = useRef({})
|
||||
const timer = useRef(null)
|
||||
|
|
@ -476,21 +481,27 @@ export default function FoodRecipeDialog({
|
|||
))}
|
||||
{searching && <p style={{ fontSize: 12, color: 'var(--text2)', padding: '6px 10px' }}>Katalog…</p>}
|
||||
{userHits.length > 0 && (
|
||||
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>Eigene Lebensmittel</div>
|
||||
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>Persönlich und zentral</div>
|
||||
)}
|
||||
{userHits.map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
className="btn btn-secondary btn-full"
|
||||
style={{ margin: '0 8px 8px', justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '8px 10px' }}
|
||||
onClick={() => addCatalog(h)}
|
||||
>
|
||||
<span>
|
||||
<strong style={{ display: 'block' }}>{h.name_de}</strong>
|
||||
<span style={{ fontSize: 12, color: 'var(--text3)' }}>{h.bls_code ? `BLS ${h.bls_code}` : 'manuell'}</span>
|
||||
</span>
|
||||
</button>
|
||||
<div key={h.id} style={{ display: 'flex', gap: 8, margin: '0 8px 8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '8px 10px' }}
|
||||
onClick={() => addCatalog(h)}
|
||||
>
|
||||
<span>
|
||||
<strong style={{ display: 'block' }}>{h.name_de}</strong>
|
||||
<span style={{ fontSize: 12, color: 'var(--text3)' }}>{foodVisibilityLabel(h.catalog_kind, h.visibility)}</span>
|
||||
</span>
|
||||
</button>
|
||||
{(h.catalog_kind === 'manual_user' || (isAdmin && h.catalog_kind === 'manual_admin')) && (
|
||||
<button type="button" className="btn btn-secondary" style={{ flexShrink: 0 }} onClick={() => setEditFoodId(h.id)}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{blsHits.length > 0 && (
|
||||
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>BLS-Katalog</div>
|
||||
|
|
@ -623,6 +634,14 @@ export default function FoodRecipeDialog({
|
|||
onSelect={(food, extras) => assignIngredient(mapFor.source_name_raw, food, extras).catch((e) => setError(e.message))}
|
||||
/>
|
||||
)}
|
||||
{editFoodId && (
|
||||
<FoodEditDialog
|
||||
foodId={editFoodId}
|
||||
zIndex={24000}
|
||||
onClose={() => setEditFoodId(null)}
|
||||
onSaved={() => runCatalog(q)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
|
||||
import FoodEditDialog from './FoodEditDialog'
|
||||
import { foodVisibilitySuffix } from '../utils/foodVisibility'
|
||||
|
||||
const COUNT_UNITS = new Set(['stück', 'scheibe', 'portion', 'becher', 'tasse'])
|
||||
const UNIT_LABEL = {
|
||||
|
|
@ -26,6 +29,7 @@ function detectUnit(...texts) {
|
|||
}
|
||||
|
||||
export default function FoodSearchModal({ title, initialQuery, quantityHint, allowRecipe, onSelect, onWantRecipe, onClose, zIndex }) {
|
||||
const { isAdmin } = useAuth()
|
||||
const [q, setQ] = useState(initialQuery || '')
|
||||
const [hits, setHits] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
|
@ -33,6 +37,7 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all
|
|||
const [creating, setCreating] = useState(false)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [createKind, setCreateKind] = useState(null)
|
||||
const [editId, setEditId] = useState(null)
|
||||
const [manual, setManual] = useState({
|
||||
name_de: initialQuery || '',
|
||||
kcal: '', protein_g: '', fat_g: '', carbs_g: '',
|
||||
|
|
@ -127,6 +132,7 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all
|
|||
const unitLabel = UNIT_LABEL[suggestedUnit] || suggestedUnit
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
|
||||
|
|
@ -186,24 +192,33 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all
|
|||
{!loading && q.trim().length >= 2 && hits.length === 0 && (
|
||||
<p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein Treffer im Katalog.</p>
|
||||
)}
|
||||
{hits.map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
className="btn btn-secondary btn-full"
|
||||
style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }}
|
||||
onClick={() => onSelect(h, unitExtras())}
|
||||
>
|
||||
<span>
|
||||
<strong style={{ display: 'block' }}>{h.name_de}</strong>
|
||||
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||
{h.bls_code ? `BLS ${h.bls_code}` : 'ohne Code'}
|
||||
{h.food_group ? ` · Gruppe ${h.food_group}` : ''}
|
||||
{h.catalog_kind !== 'official_bls' ? ' · manuell' : ''}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{hits.map((h) => {
|
||||
const manual = h.catalog_kind === 'manual_user' || (isAdmin && h.catalog_kind === 'manual_admin')
|
||||
return (
|
||||
<div key={h.id} style={{ display: 'flex', gap: 8, marginTop: 8, alignItems: 'stretch' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }}
|
||||
onClick={() => onSelect(h, unitExtras())}
|
||||
>
|
||||
<span>
|
||||
<strong style={{ display: 'block' }}>{h.name_de}</strong>
|
||||
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||
{h.bls_code ? `BLS ${h.bls_code}` : 'ohne Code'}
|
||||
{h.food_group ? ` · Gruppe ${h.food_group}` : ''}
|
||||
{foodVisibilitySuffix(h.catalog_kind, h.visibility)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{manual && (
|
||||
<button type="button" className="btn btn-secondary" style={{ flexShrink: 0 }} onClick={() => setEditId(h.id)}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
||||
{!showCreate ? (
|
||||
|
|
@ -262,5 +277,14 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{editId && (
|
||||
<FoodEditDialog
|
||||
foodId={editId}
|
||||
zIndex={(zIndex || 20000) + 2000}
|
||||
onClose={() => setEditId(null)}
|
||||
onSaved={() => runSearch(q)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
148
frontend/src/components/MyFoodsPanel.jsx
Normal file
148
frontend/src/components/MyFoodsPanel.jsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import FoodEditDialog from './FoodEditDialog'
|
||||
import { foodVisibilitySuffix } from '../utils/foodVisibility'
|
||||
|
||||
function filterByName(list, term) {
|
||||
if (!term) return list
|
||||
return list.filter((f) => (f.name_de || '').toLowerCase().includes(term))
|
||||
}
|
||||
|
||||
export default function MyFoodsPanel() {
|
||||
const { isAdmin } = useAuth()
|
||||
const [foods, setFoods] = useState([])
|
||||
const [shared, setShared] = useState([])
|
||||
const [q, setQ] = useState('')
|
||||
const [visible, setVisible] = useState(60)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [editId, setEditId] = useState(null)
|
||||
const [editMode, setEditMode] = useState('user')
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [mine, central] = await Promise.all([
|
||||
api.listMyFoods(),
|
||||
api.listSharedFoods().catch(() => []),
|
||||
])
|
||||
setFoods(Array.isArray(mine) ? mine : [])
|
||||
setShared(Array.isArray(central) ? central : [])
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const term = q.trim().toLowerCase()
|
||||
const filteredMine = useMemo(() => filterByName(foods, term), [foods, term])
|
||||
const filteredShared = useMemo(() => filterByName(shared, term), [shared, term])
|
||||
const shownMine = filteredMine.slice(0, visible)
|
||||
|
||||
const afterSave = (res) => {
|
||||
load()
|
||||
const n = res?.rebuild_pending
|
||||
if (n) setNotice(`Gespeichert. Tageswerte für ${n} Tag${n === 1 ? '' : 'e'} werden nachgerechnet.`)
|
||||
else setNotice('Lebensmittel gespeichert.')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Lebensmittel</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 12 }}>
|
||||
<strong>Persönlich:</strong> nur für dich, hier änderbar. <strong>Zentral:</strong> für alle Nutzer
|
||||
{isAdmin ? ' — als Admin hier ebenfalls änderbar' : ' (nur Admin ändert)'}.
|
||||
BLS-Katalog suchst du beim Zuordnen. Ein Öl/Supplement bleibt ein Lebensmittel, keine Liste.
|
||||
</p>
|
||||
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
|
||||
{notice && <div style={{ fontSize: 13, color: 'var(--accent-dark)', marginBottom: 10 }}>{notice}</div>}
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>Persönliches Lebensmittel anlegen</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: '100%', textAlign: 'left', marginBottom: 8 }}
|
||||
placeholder="Persönliche und zentrale Einträge suchen…"
|
||||
value={q}
|
||||
onChange={(e) => { setQ(e.target.value); setVisible(60) }}
|
||||
/>
|
||||
|
||||
<h3 style={{ fontSize: 14, margin: '16px 0 8px' }}>Persönlich ({loading ? '…' : filteredMine.length})</h3>
|
||||
{!loading && foods.length === 0 && (
|
||||
<p className="muted">Noch keine eigenen Einträge. Hier anlegen oder beim Zuordnen über „Neu anlegen“.</p>
|
||||
)}
|
||||
{!loading && foods.length > 0 && filteredMine.length === 0 && (
|
||||
<p className="muted">Kein persönlicher Treffer für „{q.trim()}“.</p>
|
||||
)}
|
||||
{shownMine.map((f) => (
|
||||
<div key={f.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '10px 0', borderTop: '1px solid var(--border)', alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditMode('user'); setEditId(f.id) }}
|
||||
style={{ background: 'none', border: 0, padding: 0, textAlign: 'left', color: 'inherit', cursor: 'pointer' }}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{f.name_de}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>nur du</div>
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" style={{ fontSize: 12 }} onClick={() => { setEditMode('user'); setEditId(f.id) }}>Bearbeiten</button>
|
||||
</div>
|
||||
))}
|
||||
{visible < filteredMine.length && (
|
||||
<button type="button" className="btn btn-secondary btn-full" style={{ marginTop: 8 }} onClick={() => setVisible((n) => n + 60)}>
|
||||
Weitere {Math.min(60, filteredMine.length - visible)} zeigen
|
||||
</button>
|
||||
)}
|
||||
|
||||
<h3 style={{ fontSize: 14, margin: '20px 0 8px' }}>Zentral für alle ({loading ? '…' : filteredShared.length})</h3>
|
||||
{!loading && shared.length === 0 && (
|
||||
<p className="muted">Noch keine zentralen Einträge vom Admin.</p>
|
||||
)}
|
||||
{!loading && shared.length > 0 && filteredShared.length === 0 && (
|
||||
<p className="muted">Kein zentraler Treffer für „{q.trim()}“.</p>
|
||||
)}
|
||||
{filteredShared.map((f) => (
|
||||
<div key={f.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '10px 0', borderTop: '1px solid var(--border)', alignItems: 'center' }}>
|
||||
{isAdmin ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditMode('admin'); setEditId(f.id) }}
|
||||
style={{ background: 'none', border: 0, padding: 0, textAlign: 'left', color: 'inherit', cursor: 'pointer' }}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{f.name_de}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>für alle Nutzer{foodVisibilitySuffix(f.catalog_kind, f.visibility)}</div>
|
||||
</button>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{f.name_de}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>für alle Nutzer{foodVisibilitySuffix(f.catalog_kind, f.visibility)}</div>
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button type="button" className="btn btn-primary" style={{ fontSize: 12 }} onClick={() => { setEditMode('admin'); setEditId(f.id) }}>Bearbeiten</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(creating || editId) && (
|
||||
<FoodEditDialog
|
||||
mode={creating ? 'user' : editMode}
|
||||
foodId={creating ? null : editId}
|
||||
onClose={() => { setCreating(false); setEditId(null) }}
|
||||
onSaved={(res) => {
|
||||
setCreating(false)
|
||||
setEditId(null)
|
||||
afterSave(res)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import FoodSearchModal from './FoodSearchModal'
|
||||
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
|
||||
import FoodRecipeDialog from './FoodRecipeDialog'
|
||||
import FoodEditDialog from './FoodEditDialog'
|
||||
import { foodVisibilitySuffix } from '../utils/foodVisibility'
|
||||
|
||||
const PERIODS = [
|
||||
{ days: 14, label: '14 Tage' },
|
||||
|
|
@ -127,7 +130,8 @@ function InlineCreate({ defaultName, disabled, allowRecipe, onCreateFood, onWant
|
|||
)
|
||||
}
|
||||
|
||||
export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, onCreatedList }) {
|
||||
export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, onCreatedList, onOpenMyFoods }) {
|
||||
const { isAdmin } = useAuth()
|
||||
const [unmapped, setUnmapped] = useState([])
|
||||
const [learned, setLearned] = useState([])
|
||||
const [recipes, setRecipes] = useState([])
|
||||
|
|
@ -144,6 +148,7 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
|
|||
const [q, setQ] = useState('')
|
||||
const [visible, setVisible] = useState(80)
|
||||
const [showLearned, setShowLearned] = useState(false)
|
||||
const [editFoodId, setEditFoodId] = useState(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const listRef = useRef(null)
|
||||
|
|
@ -371,6 +376,7 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
|
|||
<input ref={listRef} type="file" accept=".csv,text/csv" style={{ display: 'none' }} onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ''; if (f) importLists(f) }} />
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
||||
<button type="button" className="btn btn-primary" onClick={() => onManageLists?.()}>Listen verwalten</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => onOpenMyFoods?.()}>Meine Lebensmittel</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={importing} onClick={() => listRef.current?.click()}>
|
||||
{importing ? 'Importiere Listen…' : 'FDDB-Listen importieren'}
|
||||
</button>
|
||||
|
|
@ -448,7 +454,9 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
|
|||
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 13 }}>
|
||||
→ <strong>{best.name_de}</strong>
|
||||
<span style={{ color: 'var(--text3)' }}>{best.bls_code ? ` · ${best.bls_code}` : ' · manuell'}</span>
|
||||
<span style={{ color: 'var(--text3)' }}>
|
||||
{best.bls_code ? ` · ${best.bls_code}` : foodVisibilitySuffix(best.catalog_kind, best.visibility)}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" className="btn btn-primary" disabled={busyRow} onClick={() => assign(u, best.id, {}, best)}>
|
||||
Bestätigen
|
||||
|
|
@ -457,7 +465,7 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
|
|||
)}
|
||||
{suggestions.slice(1).map((s) => (
|
||||
<div key={s.id} style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text2)' }}>oder {s.name_de}{s.bls_code ? ` · ${s.bls_code}` : ''}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text2)' }}>oder {s.name_de}{s.bls_code ? ` · ${s.bls_code}` : foodVisibilitySuffix(s.catalog_kind, s.visibility)}</span>
|
||||
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => assign(u, s.id, {}, s)}>Übernehmen</button>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -510,15 +518,20 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
|
|||
</button>
|
||||
</h3>
|
||||
{showLearned && learned.map((m) => (
|
||||
<div key={m.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '8px 0', borderTop: '1px solid var(--border)' }}>
|
||||
<div key={m.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '8px 0', borderTop: '1px solid var(--border)', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{m.source_name_raw}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||
→ {m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''}
|
||||
{m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
|
||||
{foodVisibilitySuffix(m.catalog_kind, m.visibility)}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
{m.food_id && (m.catalog_kind === 'manual_user' || (isAdmin && m.catalog_kind === 'manual_admin')) && (
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setEditFoodId(m.food_id)}>Bearbeiten</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
|
@ -558,6 +571,19 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
|
|||
{recipeFor && (
|
||||
<RecipePickModal recipes={recipes} sourceName={recipeFor.source_name_raw} onClose={() => setRecipeFor(null)} onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)} />
|
||||
)}
|
||||
{editFoodId && (
|
||||
<FoodEditDialog
|
||||
foodId={editFoodId}
|
||||
onClose={() => setEditFoodId(null)}
|
||||
onSaved={(res) => {
|
||||
load()
|
||||
onChanged?.()
|
||||
const n = res?.rebuild_pending
|
||||
if (n) setNotice(`Lebensmittel gespeichert. Tageswerte für ${n} Tag${n === 1 ? '' : 'e'} werden nachgerechnet.`)
|
||||
else setNotice('Lebensmittel gespeichert.')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ export const ADMIN_GROUPS = [
|
|||
{
|
||||
to: '/admin/bls-foods',
|
||||
label: 'Lebensmittelkatalog',
|
||||
description: 'Suche inkl. BLS-Code, manuelle Einträge.',
|
||||
description: 'Suche (BLS) und Reiter Manuell für selbst angelegte Einträge.',
|
||||
},
|
||||
{
|
||||
to: '/admin/food-attributes',
|
||||
|
|
|
|||
|
|
@ -1,43 +1,180 @@
|
|||
import { useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
import FoodEditDialog from '../components/FoodEditDialog'
|
||||
|
||||
function FoodRow({ row, onEdit }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1, textAlign: 'left' }}
|
||||
onClick={() => onEdit(row)}
|
||||
>
|
||||
{row.bls_code ? `${row.bls_code} · ` : ''}{row.name_de}
|
||||
{row.catalog_kind === 'manual_admin' ? ' · zentral (alle)' : row.catalog_kind === 'manual_user' ? ' · persönlich' : ''}
|
||||
</button>
|
||||
{row.catalog_kind && row.catalog_kind !== 'official_bls' && (
|
||||
<button type="button" className="btn btn-primary" onClick={() => onEdit(row)}>Bearbeiten</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminBlsFoodsPage() {
|
||||
const [tab, setTab] = useState('search')
|
||||
const [q, setQ] = useState('')
|
||||
const [manualQ, setManualQ] = useState('')
|
||||
const [rows, setRows] = useState([])
|
||||
const [manualRows, setManualRows] = useState([])
|
||||
const [detail, setDetail] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [loadingManual, setLoadingManual] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [editId, setEditId] = useState(null)
|
||||
const timer = useRef(null)
|
||||
|
||||
const search = async () => {
|
||||
const search = async (term) => {
|
||||
try {
|
||||
setRows(await api.adminBlsFoods(q))
|
||||
setError(null)
|
||||
setRows(await api.adminBlsFoods(term, '', 50))
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const loadManual = async (term) => {
|
||||
setLoadingManual(true)
|
||||
try {
|
||||
setError(null)
|
||||
setManualRows(await api.adminBlsFoods(term, 'manual', 200))
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoadingManual(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'manual') loadManual(manualQ)
|
||||
}, [tab])
|
||||
|
||||
useEffect(() => () => clearTimeout(timer.current), [])
|
||||
|
||||
const onManualQuery = (value) => {
|
||||
setManualQ(value)
|
||||
clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => loadManual(value), 300)
|
||||
}
|
||||
|
||||
const shownManual = useMemo(() => manualRows, [manualRows])
|
||||
const centralRows = useMemo(
|
||||
() => shownManual.filter((r) => r.catalog_kind === 'manual_admin'),
|
||||
[shownManual],
|
||||
)
|
||||
const personalRows = useMemo(
|
||||
() => shownManual.filter((r) => r.catalog_kind === 'manual_user'),
|
||||
[shownManual],
|
||||
)
|
||||
|
||||
const openDetail = (id) => {
|
||||
api.adminBlsFoodDetail(id).then(setDetail).catch((e) => setError(e.message))
|
||||
}
|
||||
|
||||
const openEdit = (row) => {
|
||||
if (row.catalog_kind === 'official_bls') {
|
||||
openDetail(row.id)
|
||||
return
|
||||
}
|
||||
setDetail(null)
|
||||
setEditId(row.id)
|
||||
}
|
||||
|
||||
const list = tab === 'manual' ? shownManual : rows
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h1 className="page-title">Lebensmittelkatalog</h1>
|
||||
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
|
||||
<div className="form-row">
|
||||
<input className="form-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name oder BLS-Code" />
|
||||
<button type="button" className="btn btn-primary" onClick={search}>Suchen</button>
|
||||
</div>
|
||||
{rows.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
className="btn btn-secondary btn-full"
|
||||
style={{ marginTop: 6, textAlign: 'left' }}
|
||||
onClick={() => api.adminBlsFoodDetail(r.id).then(setDetail).catch((e) => setError(e.message))}
|
||||
>
|
||||
{r.bls_code ? `${r.bls_code} · ` : ''}{r.name_de}
|
||||
{r.catalog_kind !== 'official_bls' ? ' · manuell' : ''}
|
||||
|
||||
<div className="tabs section-gap" style={{ marginBottom: 12 }}>
|
||||
<button type="button" className={'tab' + (tab === 'search' ? ' active' : '')} onClick={() => { setTab('search'); setDetail(null) }}>
|
||||
Suche
|
||||
</button>
|
||||
<button type="button" className={'tab' + (tab === 'manual' ? ' active' : '')} onClick={() => { setTab('manual'); setDetail(null) }}>
|
||||
Manuell
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'search' && (
|
||||
<>
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', margin: '0 0 10px' }}>
|
||||
BLS und manuelle Einträge nach Name oder Code. Die komplette BLS-Liste ist zu groß zum Durchblättern.
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<input
|
||||
className="form-input"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') search(q) }}
|
||||
placeholder="Name oder BLS-Code"
|
||||
/>
|
||||
<button type="button" className="btn btn-primary" onClick={() => search(q)}>Suchen</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'manual' && (
|
||||
<>
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', margin: '0 0 10px' }}>
|
||||
Zwei Listen: zentral für alle Nutzer, darunter persönliche Einträge (sichtbar, weil du Admin bist). Beide sind hier änderbar.
|
||||
</p>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>Zentral anlegen</button>
|
||||
</div>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: '100%', textAlign: 'left', marginBottom: 10 }}
|
||||
value={manualQ}
|
||||
onChange={(e) => onManualQuery(e.target.value)}
|
||||
placeholder="In manuellen Einträgen suchen…"
|
||||
/>
|
||||
<p style={{ fontSize: 13, margin: '0 0 8px' }}>
|
||||
{loadingManual
|
||||
? 'Lade…'
|
||||
: `${centralRows.length} zentral, ${personalRows.length} persönlich`}
|
||||
{!loadingManual && shownManual.length >= 200 ? ' (Anzeige begrenzt auf 200)' : ''}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'search' && rows.length === 0 && (
|
||||
<p className="muted">Suchbegriff eingeben und Suchen.</p>
|
||||
)}
|
||||
{tab === 'manual' && !loadingManual && shownManual.length === 0 && (
|
||||
<p className="muted">Keine manuellen Lebensmittel{manualQ.trim() ? ` zu „${manualQ.trim()}“` : ''}.</p>
|
||||
)}
|
||||
|
||||
{tab === 'search' && list.map((r) => (
|
||||
<FoodRow key={r.id} row={r} onEdit={openEdit} />
|
||||
))}
|
||||
{tab === 'manual' && !loadingManual && shownManual.length > 0 && (
|
||||
<>
|
||||
<h3 style={{ fontSize: 14, margin: '16px 0 8px' }}>Zentral für alle ({centralRows.length})</h3>
|
||||
{centralRows.length === 0 && <p className="muted">Noch keine zentralen Einträge.</p>}
|
||||
{centralRows.map((r) => (
|
||||
<FoodRow key={r.id} row={r} onEdit={openEdit} />
|
||||
))}
|
||||
<h3 style={{ fontSize: 14, margin: '20px 0 8px' }}>Persönlich ({personalRows.length})</h3>
|
||||
{personalRows.length === 0 && <p className="muted">Keine persönlichen Einträge in dieser Auswahl.</p>}
|
||||
{personalRows.map((r) => (
|
||||
<FoodRow key={r.id} row={r} onEdit={openEdit} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{detail && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<h3>{detail.bls_code} {detail.name_de}</h3>
|
||||
<h3>{detail.bls_code ? `${detail.bls_code} · ` : ''}{detail.name_de}</h3>
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)' }}>{detail.catalog_kind} · {detail.bls_version || '—'}</p>
|
||||
<table style={{ width: '100%', fontSize: 12 }}>
|
||||
<tbody>
|
||||
|
|
@ -52,6 +189,18 @@ export default function AdminBlsFoodsPage() {
|
|||
</table>
|
||||
</div>
|
||||
)}
|
||||
{(creating || editId) && (
|
||||
<FoodEditDialog
|
||||
mode="admin"
|
||||
foodId={creating ? null : editId}
|
||||
onClose={() => { setCreating(false); setEditId(null) }}
|
||||
onSaved={() => {
|
||||
setCreating(false)
|
||||
setEditId(null)
|
||||
if (tab === 'manual') loadManual(manualQ)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,138 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
const GROUP_OPTIONS = [
|
||||
{ id: 'energy', label: 'Energie' },
|
||||
{ id: 'macro', label: 'Makronährstoffe' },
|
||||
{ id: 'carbs', label: 'Kohlenhydrate' },
|
||||
{ id: 'fiber', label: 'Ballaststoffe' },
|
||||
{ id: 'fatty_acids', label: 'Fettsäuren' },
|
||||
{ id: 'amino_acids', label: 'Aminosäuren' },
|
||||
{ id: 'vitamins', label: 'Vitamine' },
|
||||
{ id: 'minerals', label: 'Mineralstoffe' },
|
||||
{ id: 'organic_acids', label: 'Organische Säuren' },
|
||||
{ id: 'polyols', label: 'Zuckeralkohole' },
|
||||
{ id: 'other', label: 'Sonstige' },
|
||||
{ id: 'extension', label: 'Eigene Erweiterungen' },
|
||||
{ id: 'unknown', label: 'Ohne Gruppe' },
|
||||
]
|
||||
|
||||
const DEFAULT_OPEN = new Set(['energy', 'macro'])
|
||||
|
||||
function matches(row, term, filter) {
|
||||
if (filter === 'needs' && !row.needs_label) return false
|
||||
if (filter !== 'all' && filter !== 'needs' && row.group_key !== filter) return false
|
||||
if (!term) return true
|
||||
const hay = `${row.attr_key} ${row.name_de || ''} ${row.name_en || ''} ${row.unit || ''} ${row.group_label || ''}`.toLowerCase()
|
||||
return hay.includes(term)
|
||||
}
|
||||
|
||||
function groupRows(rows) {
|
||||
const byKey = new Map()
|
||||
for (const row of rows) {
|
||||
const key = row.group_key || 'unknown'
|
||||
if (!byKey.has(key)) {
|
||||
byKey.set(key, {
|
||||
key,
|
||||
label: row.group_label || 'Ohne Gruppe',
|
||||
rank: row.group_rank ?? 99,
|
||||
items: [],
|
||||
})
|
||||
}
|
||||
byKey.get(key).items.push(row)
|
||||
}
|
||||
return [...byKey.values()].sort((a, b) => a.rank - b.rank)
|
||||
}
|
||||
|
||||
export default function AdminFoodAttributesPage() {
|
||||
const [rows, setRows] = useState([])
|
||||
const [form, setForm] = useState({ attr_key: '', name_de: '', unit: '', data_type: 'num_per_100g' })
|
||||
const [q, setQ] = useState('')
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [open, setOpen] = useState(() => new Set(DEFAULT_OPEN))
|
||||
const [editId, setEditId] = useState(null)
|
||||
const [draft, setDraft] = useState({ name_de: '', unit: '', category: '' })
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = () => api.adminBlsAttributes().then(setRows).catch((e) => setError(e.message))
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const term = q.trim().toLowerCase()
|
||||
const shown = useMemo(() => rows.filter((r) => matches(r, term, filter)), [rows, term, filter])
|
||||
const groups = useMemo(() => groupRows(shown), [shown])
|
||||
const missing = rows.filter((r) => r.needs_label).length
|
||||
const groupCounts = useMemo(() => {
|
||||
const counts = {}
|
||||
for (const r of rows) {
|
||||
const key = r.group_key || 'unknown'
|
||||
counts[key] = (counts[key] || 0) + 1
|
||||
}
|
||||
return counts
|
||||
}, [rows])
|
||||
|
||||
useEffect(() => {
|
||||
if (term || filter !== 'all') {
|
||||
setOpen(new Set(groups.map((g) => g.key)))
|
||||
}
|
||||
}, [term, filter, groups])
|
||||
|
||||
const toggle = (key) => {
|
||||
setOpen((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const startEdit = (r) => {
|
||||
setEditId(r.id)
|
||||
setDraft({ name_de: r.name_de || '', unit: r.unit || '', category: r.group_key || r.category || '' })
|
||||
setError(null)
|
||||
setOpen((prev) => new Set(prev).add(r.group_key || 'unknown'))
|
||||
}
|
||||
|
||||
const saveEdit = async (id) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const updated = await api.adminUpdateBlsAttribute(id, draft)
|
||||
setRows((list) => list.map((r) => (r.id === id ? { ...r, ...updated } : r)))
|
||||
setEditId(null)
|
||||
setNotice('Bezeichnung gespeichert. Der Schlüssel bleibt unverändert.')
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fillLabels = async () => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await api.adminFillBlsAttributeLabels()
|
||||
await load()
|
||||
setNotice(`${res.updated ?? 0} Bezeichnungen/Gruppen aus dem BLS-4.0-Glossar ergänzt.`)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h1 className="page-title">Stoffe & Attribute</h1>
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)' }}>
|
||||
Offizielle BLS-Codes kommen aus dem Import. Erweiterungen (Histamin, Gluten, …) hier anlegen — ohne Migration.
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.55, margin: '0 0 12px' }}>
|
||||
Gruppiert nach BLS-Nährstoffgruppen. Innerhalb der Gruppe: zuerst die vier Makros für die Haupttabelle, dann Name.
|
||||
Schlüssel bleibt fest, Bezeichnung und Gruppe sind änderbar.
|
||||
</p>
|
||||
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
|
||||
<div className="form-row">
|
||||
{notice && <p style={{ color: 'var(--accent-dark)', fontSize: 13 }}>{notice}</p>}
|
||||
|
||||
<div className="form-row" style={{ marginBottom: 12 }}>
|
||||
<input className="form-input" placeholder="KEY" value={form.attr_key} onChange={(e) => setForm({ ...form, attr_key: e.target.value })} />
|
||||
<input className="form-input" placeholder="Name DE" value={form.name_de} onChange={(e) => setForm({ ...form, name_de: e.target.value })} />
|
||||
<input className="form-input" placeholder="Einheit" value={form.unit} onChange={(e) => setForm({ ...form, unit: e.target.value })} />
|
||||
|
|
@ -26,19 +142,115 @@ export default function AdminFoodAttributesPage() {
|
|||
<option value="text">Text</option>
|
||||
<option value="enum">Auswahl</option>
|
||||
</select>
|
||||
<button type="button" className="btn btn-primary" onClick={async () => {
|
||||
try {
|
||||
await api.adminCreateBlsAttribute(form)
|
||||
setForm({ attr_key: '', name_de: '', unit: '', data_type: 'num_per_100g' })
|
||||
load()
|
||||
} catch (e) { setError(e.message) }
|
||||
}}>Anlegen</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.adminCreateBlsAttribute(form)
|
||||
setForm({ attr_key: '', name_de: '', unit: '', data_type: 'num_per_100g' })
|
||||
load()
|
||||
} catch (e) { setError(e.message) }
|
||||
}}
|
||||
>
|
||||
Anlegen
|
||||
</button>
|
||||
</div>
|
||||
{rows.map((r) => (
|
||||
<div key={r.id} style={{ fontSize: 13, padding: '6px 0', borderTop: '1px solid var(--border)' }}>
|
||||
<strong>{r.attr_key}</strong> · {r.name_de} · {r.data_type} · {r.origin}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 10 }}>
|
||||
<button type="button" className={'btn ' + (filter === 'all' ? 'btn-primary' : 'btn-secondary')} style={{ fontSize: 12 }} onClick={() => { setFilter('all'); setOpen(new Set(DEFAULT_OPEN)) }}>
|
||||
Alle ({rows.length})
|
||||
</button>
|
||||
{GROUP_OPTIONS.filter((g) => groupCounts[g.id]).map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
className={'btn ' + (filter === g.id ? 'btn-primary' : 'btn-secondary')}
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() => setFilter(g.id)}
|
||||
>
|
||||
{g.label} ({groupCounts[g.id]})
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className={'btn ' + (filter === 'needs' ? 'btn-primary' : 'btn-secondary')} style={{ fontSize: 12 }} onClick={() => setFilter('needs')}>
|
||||
Ohne Bezeichnung{missing ? ` (${missing})` : ''}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => setOpen(new Set(groups.map((g) => g.key)))}>
|
||||
Alle aufklappen
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => setOpen(new Set())}>
|
||||
Alle zuklappen
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} disabled={busy} onClick={fillLabels}>
|
||||
Namen/Gruppen aus Glossar füllen
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: '100%', textAlign: 'left', marginBottom: 10 }}
|
||||
placeholder="In allen Gruppen suchen…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<p style={{ fontSize: 13, margin: '0 0 8px' }}>{shown.length} von {rows.length} Stoffen · {groups.length} Gruppen</p>
|
||||
|
||||
{groups.map((g) => {
|
||||
const opened = open.has(g.key)
|
||||
return (
|
||||
<section key={g.key} style={{ marginBottom: 8, border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(g.key)}
|
||||
style={{
|
||||
width: '100%', textAlign: 'left', display: 'flex', justifyContent: 'space-between',
|
||||
gap: 8, padding: '10px 12px', background: 'var(--surface2)', border: 0,
|
||||
cursor: 'pointer', fontFamily: 'inherit', color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>{g.label}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text3)' }}>{g.items.length} · {opened ? 'zuklappen' : 'aufklappen'}</span>
|
||||
</button>
|
||||
{opened && g.items.map((r) => (
|
||||
<div key={r.id} style={{ padding: '8px 12px', borderTop: '1px solid var(--border)' }}>
|
||||
{editId === r.id ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 6 }}>
|
||||
Schlüssel {r.attr_key} · {r.origin}{r.is_macro ? ' · Makro → nutrition_log' : ''}
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<input className="form-input" value={draft.name_de} onChange={(e) => setDraft({ ...draft, name_de: e.target.value })} placeholder="Name DE" />
|
||||
<input className="form-input" value={draft.unit} onChange={(e) => setDraft({ ...draft, unit: e.target.value })} placeholder="Einheit" />
|
||||
<select className="form-input" value={draft.category} onChange={(e) => setDraft({ ...draft, category: e.target.value })}>
|
||||
{GROUP_OPTIONS.map((opt) => (
|
||||
<option key={opt.id} value={opt.id}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => saveEdit(r.id)}>Speichern</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => setEditId(null)}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{r.needs_label ? r.attr_key : (r.name_de || r.attr_key)}
|
||||
{r.is_macro ? <span style={{ marginLeft: 8, fontSize: 11, color: 'var(--accent-dark)' }}>Makro</span> : null}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||
{r.attr_key}
|
||||
{r.needs_label ? ' · Bezeichnung fehlt' : ''}
|
||||
{r.unit ? ` · ${r.unit}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" style={{ fontSize: 12 }} onClick={() => startEdit(r)}>Bearbeiten</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
import { foodVisibilityLabel } from '../utils/foodVisibility'
|
||||
|
||||
export default function AdminFoodMappingsPage() {
|
||||
const [rows, setRows] = useState([])
|
||||
|
|
@ -30,7 +31,7 @@ export default function AdminFoodMappingsPage() {
|
|||
setter([])
|
||||
return
|
||||
}
|
||||
api.searchBlsFoods(q, 20).then(setter).catch(() => setter([]))
|
||||
api.searchBlsFoods(q, 20, undefined, 'global').then(setter).catch(() => setter([]))
|
||||
}
|
||||
|
||||
const shown = rows.filter((r) => {
|
||||
|
|
@ -59,6 +60,10 @@ export default function AdminFoodMappingsPage() {
|
|||
<div className="card">
|
||||
<h1 className="page-title">FDDB→BLS Mappings</h1>
|
||||
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', margin: '0 0 10px' }}>
|
||||
Globale Zuordnungen gelten für alle Nutzer und dürfen nur auf BLS oder zentrale Lebensmittel zeigen.
|
||||
Persönliche Nutzer-Mappings bleiben beim jeweiligen Profil.
|
||||
</p>
|
||||
{coverage && (
|
||||
<p style={{ fontSize: 13 }}>
|
||||
{coverage.mapped_items}/{coverage.total_items} Zeilen gemappt ·
|
||||
|
|
@ -77,7 +82,7 @@ export default function AdminFoodMappingsPage() {
|
|||
</div>
|
||||
{hits.map((h) => (
|
||||
<button key={h.id} type="button" className="btn btn-secondary" style={{ margin: 4 }} onClick={() => { setFoodId(h.id); setFoodQ(h.name_de) }}>
|
||||
{h.bls_code} {h.name_de}
|
||||
{h.bls_code} {h.name_de}{h.catalog_kind === 'manual_admin' ? ' · zentral' : h.bls_code ? '' : ' · BLS'}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
|
|
@ -100,7 +105,10 @@ export default function AdminFoodMappingsPage() {
|
|||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
{r.source_name_raw} → {r.bls_code} {r.food_name_de}
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)' }}>{r.profile_id ? 'user' : 'global'}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
|
||||
{r.profile_id ? 'Nutzer-Mapping' : 'globales Mapping'}
|
||||
{r.catalog_kind ? ` · ${foodVisibilityLabel(r.catalog_kind, r.visibility)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => { setEditId(editId === r.id ? null : r.id); setEditQ(''); setEditHits([]) }}>
|
||||
|
|
@ -131,7 +139,7 @@ export default function AdminFoodMappingsPage() {
|
|||
disabled={saving}
|
||||
onClick={() => applyChange(r.id, h)}
|
||||
>
|
||||
{h.name_de}{h.bls_code ? ` · ${h.bls_code}` : ''}
|
||||
{h.name_de}{h.bls_code ? ` · ${h.bls_code}` : ''} · {foodVisibilityLabel(h.catalog_kind, h.visibility)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react'
|
|||
import UsageBadge from '../components/UsageBadge'
|
||||
import NutritionFoodMap, { DayMarkButtons } from '../components/NutritionFoodMap'
|
||||
import FoodRecipeEditor from '../components/FoodRecipeEditor'
|
||||
import MyFoodsPanel from '../components/MyFoodsPanel'
|
||||
import {
|
||||
LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip,
|
||||
ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter
|
||||
|
|
@ -957,7 +958,7 @@ export default function NutritionPage() {
|
|||
)}
|
||||
|
||||
{/* Input Method Tabs */}
|
||||
<div className="tabs section-gap" style={{marginBottom:0}}>
|
||||
<div className="tabs section-gap" style={{marginBottom:0, overflowX:'auto', flexWrap:'nowrap'}}>
|
||||
<button className={'tab'+(inputTab==='entry'?' active':'')} onClick={()=>setInputTab('entry')}>
|
||||
✏️ Einzelerfassung
|
||||
</button>
|
||||
|
|
@ -967,6 +968,9 @@ export default function NutritionPage() {
|
|||
<button className={'tab'+(inputTab==='map'?' active':'')} onClick={()=>setInputTab('map')}>
|
||||
Zuordnen{unmappedCount ? ` (${unmappedCount})` : ''}
|
||||
</button>
|
||||
<button className={'tab'+(inputTab==='foods'?' active':'')} onClick={()=>setInputTab('foods')}>
|
||||
Lebensmittel
|
||||
</button>
|
||||
<button className={'tab'+(inputTab==='lists'?' active':'')} onClick={()=>setInputTab('lists')}>
|
||||
Listen
|
||||
</button>
|
||||
|
|
@ -992,9 +996,12 @@ export default function NutritionPage() {
|
|||
onChanged={refreshUnmappedCount}
|
||||
onManageLists={(id) => { setFocusListId(id || null); setInputTab('lists') }}
|
||||
onCreatedList={(rec) => { setFocusListId(rec?.id || null); setInputTab('lists') }}
|
||||
onOpenMyFoods={() => setInputTab('foods')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{inputTab==='foods' && <MyFoodsPanel />}
|
||||
|
||||
{inputTab==='lists' && (
|
||||
<FoodRecipeEditor
|
||||
highlightId={focusListId}
|
||||
|
|
@ -1002,9 +1009,9 @@ export default function NutritionPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{loading && inputTab !== 'map' && inputTab !== 'lists' && <div className="empty-state"><div className="spinner"/></div>}
|
||||
{loading && inputTab !== 'map' && inputTab !== 'lists' && inputTab !== 'foods' && <div className="empty-state"><div className="spinner"/></div>}
|
||||
|
||||
{!loading && !hasData && inputTab !== 'map' && inputTab !== 'lists' && (
|
||||
{!loading && !hasData && inputTab !== 'map' && inputTab !== 'lists' && inputTab !== 'foods' && (
|
||||
<div className="empty-state">
|
||||
<h3>Noch keine Ernährungsdaten</h3>
|
||||
<p>Erfasse Daten über Einzelerfassung oder importiere deinen FDDB-Export.</p>
|
||||
|
|
@ -1012,7 +1019,7 @@ export default function NutritionPage() {
|
|||
)}
|
||||
|
||||
{/* Analysis Section */}
|
||||
{!loading && hasData && inputTab !== 'map' && inputTab !== 'lists' && (
|
||||
{!loading && hasData && inputTab !== 'map' && inputTab !== 'lists' && inputTab !== 'foods' && (
|
||||
<>
|
||||
<OverviewCards data={corrData}/>
|
||||
|
||||
|
|
|
|||
|
|
@ -285,10 +285,15 @@ export const api = {
|
|||
deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}),
|
||||
resolveNutritionConflicts: (decisions) => req('/nutrition/import-conflicts/resolve', json({decisions})),
|
||||
listFoodUnits: () => req('/bls/units'),
|
||||
searchBlsFoods: (q, limit=20, signal) => req(`/bls/foods?q=${encodeURIComponent(q||'')}&limit=${limit}`, signal ? { signal } : {}),
|
||||
searchBlsFoods: (q, limit=20, signal, scope) => req(`/bls/foods?q=${encodeURIComponent(q||'')}&limit=${limit}${scope?`&scope=${encodeURIComponent(scope)}`:''}`, signal ? { signal } : {}),
|
||||
suggestFoodsBatch: (names, limit=3) => req('/bls/foods/suggest-batch', json({ names, limit })),
|
||||
createUserFood: (d) => req('/bls/foods/manual', json(d)),
|
||||
listFoodAttributes: (q='', limit=40) => req(`/bls/attributes?q=${encodeURIComponent(q||'')}&limit=${limit}`),
|
||||
listMyFoods: () => req('/bls/foods/mine'),
|
||||
listSharedFoods: () => req('/bls/foods/shared'),
|
||||
getBlsFood: (id) => req(`/bls/foods/${encodeURIComponent(id)}`),
|
||||
updateUserFood: (id, d) => req(`/bls/foods/manual/${encodeURIComponent(id)}`, jput(d)),
|
||||
listFoodAttributes: (q='', limit=40, keys='') => req(`/bls/attributes?q=${encodeURIComponent(q||'')}&limit=${limit}${keys ? `&keys=${encodeURIComponent(keys)}` : ''}`),
|
||||
createFoodAttribute: (d) => req('/bls/attributes', json(d)),
|
||||
listMyFoodMappings: () => req('/bls/mappings'),
|
||||
upsertMyFoodMapping: (d) => req('/bls/mappings', json(d)),
|
||||
deleteMyFoodMapping: (id) => req(`/bls/mappings/${id}`, {method:'DELETE'}),
|
||||
|
|
@ -300,11 +305,14 @@ export const api = {
|
|||
},
|
||||
adminBlsImportJob: (id) => req(`/admin/bls/import/jobs/${id}`),
|
||||
adminBlsImportApply: (id) => req(`/admin/bls/import/jobs/${id}/apply`, {method:'POST'}),
|
||||
adminBlsFoods: (q, kind) => req(`/admin/bls/foods?${q?('q='+encodeURIComponent(q)+'&'):''}${kind?('kind='+kind):''}`),
|
||||
adminBlsFoodDetail: (id) => req(`/admin/bls/foods/${id}`),
|
||||
adminBlsFoods: (q, kind, limit) => req(`/admin/bls/foods?${q?('q='+encodeURIComponent(q)+'&'):''}${kind?('kind='+encodeURIComponent(kind)+'&'):''}${limit?('limit='+limit):''}`),
|
||||
adminBlsFoodDetail: (id, editor=false) => req(`/admin/bls/foods/${encodeURIComponent(id)}${editor?'?editor=true':''}`),
|
||||
adminCreateManualFood: (d) => req('/admin/bls/foods/manual', json(d)),
|
||||
adminUpdateManualFood: (id, d) => req(`/admin/bls/foods/manual/${encodeURIComponent(id)}`, jput(d)),
|
||||
adminBlsAttributes: () => req('/admin/bls/attributes'),
|
||||
adminCreateBlsAttribute: (d) => req('/admin/bls/attributes', json(d)),
|
||||
adminUpdateBlsAttribute: (id, d) => req(`/admin/bls/attributes/${id}`, jput(d)),
|
||||
adminFillBlsAttributeLabels: () => req('/admin/bls/attributes/fill-labels', { method: 'POST' }),
|
||||
adminListFoodMappings: (globalOnly) => req(`/admin/food-mappings${globalOnly?'?global_only=true':''}`),
|
||||
adminCreateFoodMapping: (d) => req('/admin/food-mappings', json(d)),
|
||||
adminUpdateFoodMapping: (id, d) => req(`/admin/food-mappings/${id}`, jput(d)),
|
||||
|
|
|
|||
23
frontend/src/utils/foodVisibility.js
Normal file
23
frontend/src/utils/foodVisibility.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
const LABELS = {
|
||||
personal: 'persönlich',
|
||||
global: 'zentral',
|
||||
bls: 'BLS',
|
||||
}
|
||||
|
||||
export function foodVisibility(kind, visibility) {
|
||||
if (visibility && LABELS[visibility]) return visibility
|
||||
if (kind === 'official_bls') return 'bls'
|
||||
if (kind === 'manual_admin') return 'global'
|
||||
if (kind === 'manual_user') return 'personal'
|
||||
return ''
|
||||
}
|
||||
|
||||
export function foodVisibilityLabel(kind, visibility) {
|
||||
const key = foodVisibility(kind, visibility)
|
||||
return LABELS[key] || ''
|
||||
}
|
||||
|
||||
export function foodVisibilitySuffix(kind, visibility) {
|
||||
const label = foodVisibilityLabel(kind, visibility)
|
||||
return label ? ` · ${label}` : ''
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user