diff --git a/.claude/docs/functional/BLS_FOOD_REFERENCE.md b/.claude/docs/functional/BLS_FOOD_REFERENCE.md index 86dc3f0..7fad517 100644 --- a/.claude/docs/functional/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/functional/BLS_FOOD_REFERENCE.md @@ -48,7 +48,7 @@ Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer a 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. -**Nachträglich ändern:** Unter Ernährung → Tab **Lebensmittel** (persönliche Liste + zentrale Admin-Einträge, Suche). Persönliche Einträge anlegen/bearbeiten; zentrale nur lesen. 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** → Zentral anlegen. +**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. diff --git a/.claude/docs/technical/BLS_FOOD_REFERENCE.md b/.claude/docs/technical/BLS_FOOD_REFERENCE.md index 840acbe..c399f86 100644 --- a/.claude/docs/technical/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/technical/BLS_FOOD_REFERENCE.md @@ -29,9 +29,9 @@ 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; `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`; `PUT /bls/foods/manual/{id}` — nur Owner. Suche `?scope=global` blendet persönliche Foods aus (Admin-Mappings). +- `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/PUT /admin/bls/foods/manual` — zentrale Foods (`attributes` + `serving_g`). Migration **069**: Owner-Check (persönlich hat Owner, BLS/Admin nicht). +- `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/food-mappings` — Admin-CRUD diff --git a/CLAUDE.md b/CLAUDE.md index 5d89dad..96c1d7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:** Persönlich (`manual_user` + Owner, nur dieser Nutzer) oder zentral (`manual_admin`, alle Nutzer). Stoffe über EAV. Nutzer: Tab Ernährung → **Lebensmittel**. Admin: Katalog → Manuell → **Zentral anlegen**. Mapping: Nutzer-Mapping darf sichtbare Foods; globales Mapping nur BLS/zentral. Offizielle BLS-Zeilen nicht editierbar. Öl/Supplement = ein Lebensmittel, keine Liste. +- **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. 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. diff --git a/backend/data_layer/food_visibility.py b/backend/data_layer/food_visibility.py index 2993449..0fe6ce5 100644 --- a/backend/data_layer/food_visibility.py +++ b/backend/data_layer/food_visibility.py @@ -62,7 +62,7 @@ def is_visible_to(food: dict[str, Any] | None, profile_id: str | None) -> bool: owner = food.get("owner_profile_id") if owner is None: return True - return profile_id is not None and str(owner) == str(profile_id) + return profile_id is not None and same_id(owner, profile_id) def is_global_mapping_target(food: dict[str, Any] | None) -> bool: @@ -104,6 +104,18 @@ def require_mapping_target(cur, food_id: str, mapping_profile_id: str | None) -> 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 diff --git a/backend/routers/bls.py b/backend/routers/bls.py index f45723d..9a7f75a 100644 --- a/backend/routers/bls.py +++ b/backend/routers/bls.py @@ -238,8 +238,8 @@ def get_food_detail(food_id: str, session: dict = Depends(require_auth)): if not food: raise HTTPException(404, "Lebensmittel nicht gefunden") food = dict(food) - from data_layer.food_visibility import attach_visibility, same_id - editable = food.get("catalog_kind") == "manual_user" and same_id(food.get("owner_profile_id"), pid) + 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)) diff --git a/backend/tests/test_food_visibility.py b/backend/tests/test_food_visibility.py index 56b77ae..07a1f42 100644 --- a/backend/tests/test_food_visibility.py +++ b/backend/tests/test_food_visibility.py @@ -1,5 +1,6 @@ from data_layer.food_visibility import ( attach_visibility, + can_edit_food, is_global_mapping_target, is_visible_to, require_mapping_target, @@ -9,6 +10,18 @@ from data_layer.food_visibility import ( ) +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") diff --git a/backend/version.py b/backend/version.py index 4ce2790..89c7f0e 100644 --- a/backend/version.py +++ b/backend/version.py @@ -22,7 +22,7 @@ MODULE_VERSIONS = { "activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement "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.10", # Manuelle Liste: Zeile öffnet Editor; Admin darf alle Manuals ändern + "bls": "1.0.11", # Zentrale Manuals: Admin-Editor inkl. Nutzerliste; Admin-Katalog getrennt "photos": "1.0.0", "insights": "1.3.0", diff --git a/frontend/src/components/FoodEditDialog.jsx b/frontend/src/components/FoodEditDialog.jsx index 7c7a77b..aa943db 100644 --- a/frontend/src/components/FoodEditDialog.jsx +++ b/frontend/src/components/FoodEditDialog.jsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { api } from '../utils/api' +import { useAuth } from '../context/AuthContext' import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields' function numStr(v) { @@ -9,17 +10,25 @@ function numStr(v) { } export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode }) { + const { isAdmin: sessionAdmin } = useAuth() || {} const isCreate = !foodId - const isAdmin = mode === 'admin' + 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) @@ -27,10 +36,13 @@ export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode return } let alive = true - const load = isAdmin ? api.adminBlsFoodDetail(foodId, true) : api.getBlsFood(foodId) + 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), @@ -38,13 +50,15 @@ export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode fat_g: numStr(m.fat_g), carbs_g: numStr(m.carbs_g), }) - setExtras((d.attributes || []).map((row) => ({ + 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) || (isAdmin && d.catalog_kind && d.catalog_kind !== 'official_bls') + const canEdit = Boolean(d.editable) || ( + sessionAdmin && d.catalog_kind && d.catalog_kind !== 'official_bls' + ) setEditable(canEdit) setLoading(false) }).catch((e) => { @@ -53,7 +67,7 @@ export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode setLoading(false) }) return () => { alive = false } - }, [foodId, isCreate, isAdmin]) + }, [foodId, isCreate, sessionAdmin, mode]) const save = async () => { if (!name.trim()) { @@ -65,7 +79,7 @@ export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode try { const body = foodCreatePayload(name, macros, extras, servingG) let saved - if (isAdmin) { + 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) @@ -79,6 +93,10 @@ export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode } } + const title = isCreate + ? (createAsAdmin ? 'Zentrales Lebensmittel anlegen' : 'Lebensmittel anlegen') + : (catalogKind === 'manual_admin' ? 'Zentrales Lebensmittel bearbeiten' : 'Lebensmittel bearbeiten') + return (
- Offizielle BLS-Einträge sind fest. Nur selbst angelegte{isAdmin ? ' zentrale' : ''} Lebensmittel lassen sich ändern. + {catalogKind === 'manual_admin' + ? 'Zentrale Lebensmittel ändert nur ein Admin.' + : 'Offizielle BLS-Einträge sind fest. Nur selbst angelegte Lebensmittel lassen sich ändern.'}
)} {!loading && editable && ( diff --git a/frontend/src/components/FoodRecipeDialog.jsx b/frontend/src/components/FoodRecipeDialog.jsx index 95f635c..841089f 100644 --- a/frontend/src/components/FoodRecipeDialog.jsx +++ b/frontend/src/components/FoodRecipeDialog.jsx @@ -1,5 +1,6 @@ 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' @@ -113,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( @@ -494,7 +496,7 @@ export default function FoodRecipeDialog({ {foodVisibilityLabel(h.catalog_kind, h.visibility)} - {h.catalog_kind === 'manual_user' && ( + {(h.catalog_kind === 'manual_user' || (isAdmin && h.catalog_kind === 'manual_admin')) && ( diff --git a/frontend/src/components/FoodSearchModal.jsx b/frontend/src/components/FoodSearchModal.jsx index 08e5c8c..10ab244 100644 --- a/frontend/src/components/FoodSearchModal.jsx +++ b/frontend/src/components/FoodSearchModal.jsx @@ -1,5 +1,6 @@ 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' @@ -28,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) @@ -191,7 +193,7 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, allKein Treffer im Katalog.
)} {hits.map((h) => { - const manual = h.catalog_kind && h.catalog_kind !== 'official_bls' && h.catalog_kind !== 'manual_admin' + const manual = h.catalog_kind === 'manual_user' || (isAdmin && h.catalog_kind === 'manual_admin') return (- Zentral (für alle Nutzer) und persönlich (Nutzer-eigene). Zeile oder Bearbeiten öffnet den Editor. + Zwei Listen: zentral für alle Nutzer, darunter persönliche Einträge (sichtbar, weil du Admin bist). Beide sind hier änderbar.
- {loadingManual ? 'Lade…' : `${shownManual.length} Einträge`} + {loadingManual + ? 'Lade…' + : `${centralRows.length} zentral, ${personalRows.length} persönlich`} {!loadingManual && shownManual.length >= 200 ? ' (Anzeige begrenzt auf 200)' : ''}
> @@ -126,22 +155,23 @@ export default function AdminBlsFoodsPage() {Keine manuellen Lebensmittel{manualQ.trim() ? ` zu „${manualQ.trim()}“` : ''}.
)} - {list.map((r) => ( -Noch keine zentralen Einträge.
} + {centralRows.map((r) => ( +Keine persönlichen Einträge in dieser Auswahl.
} + {personalRows.map((r) => ( +