fix: manuelle Lebensmittel in der Liste wieder bearbeiten
All checks were successful
Deploy Development / deploy (push) Successful in 1m6s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s

Admin-Katalog und Nutzer-Liste oeffnen den Editor statt nur die Lesansicht; PUT gilt fuer zentrale und persoenliche Manuals.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-09-14 12:55:09 +02:00
parent 8b1e1da5d8
commit 9a0767c8ac
9 changed files with 59 additions and 20 deletions

View File

@ -29,6 +29,10 @@ _KIND_VIS = {
_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

View File

@ -188,7 +188,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:
@ -197,6 +197,13 @@ 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}
@ -252,13 +259,13 @@ def admin_update_manual_food(food_id: str, body: ManualFoodCreate, session: dict
"""
SELECT id, name_de, name_en, catalog_kind, owner_profile_id
FROM food_catalog
WHERE id = %s AND catalog_kind = 'manual_admin' AND is_active = true
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, "Zentrales Lebensmittel nicht gefunden")
raise HTTPException(404, "Manuelles Lebensmittel nicht gefunden")
cur.execute(
"""
UPDATE food_catalog

View File

@ -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)
editable = food.get("catalog_kind") == "manual_user" and str(food.get("owner_profile_id") or "") == str(pid)
from data_layer.food_visibility import attach_visibility
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)
return attach_visibility(editor_payload_for_food(cur, food, editable=editable))

View File

@ -3,11 +3,16 @@ from data_layer.food_visibility import (
is_global_mapping_target,
is_visible_to,
require_mapping_target,
same_id,
visibility_of,
visibility_rank,
)
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"

View File

@ -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.9", # Nutzer-Foods privat, Admin-Foods zentral; Mapping achtet Sichtbarkeit
"bls": "1.0.10", # Manuelle Liste: Zeile öffnet Editor; Admin darf alle Manuals ändern
"photos": "1.0.0",
"insights": "1.3.0",

View File

@ -27,7 +27,8 @@ export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode
return
}
let alive = true
api.getBlsFood(foodId).then((d) => {
const load = isAdmin ? api.adminBlsFoodDetail(foodId, true) : api.getBlsFood(foodId)
load.then((d) => {
if (!alive) return
setName(d.name_de || '')
const m = d.macros_per_100g || {}
@ -43,7 +44,8 @@ export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode
inputUnit: row.attr?.unit || 'g',
})))
setServingG(numStr(d.serving_g || 100) === '0' ? '100' : numStr(d.serving_g || 100))
setEditable(isAdmin ? d.catalog_kind === 'manual_admin' : Boolean(d.editable))
const canEdit = Boolean(d.editable) || (isAdmin && d.catalog_kind && d.catalog_kind !== 'official_bls')
setEditable(canEdit)
setLoading(false)
}).catch((e) => {
if (!alive) return

View File

@ -1,5 +1,6 @@
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'
@ -9,6 +10,7 @@ function filterByName(list, term) {
}
export default function MyFoodsPanel() {
const { isAdmin } = useAuth()
const [foods, setFoods] = useState([])
const [shared, setShared] = useState([])
const [q, setQ] = useState('')
@ -17,6 +19,7 @@ export default function MyFoodsPanel() {
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 () => {
@ -80,11 +83,15 @@ export default function MyFoodsPanel() {
)}
{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' }}>
<div>
<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>
</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => setEditId(f.id)}>Bearbeiten</button>
</button>
<button type="button" className="btn btn-primary" style={{ fontSize: 12 }} onClick={() => { setEditMode('user'); setEditId(f.id) }}>Bearbeiten</button>
</div>
))}
{visible < filteredMine.length && (
@ -101,14 +108,20 @@ export default function MyFoodsPanel() {
<p className="muted">Kein zentraler Treffer für „{q.trim()}“.</p>
)}
{filteredShared.map((f) => (
<div key={f.id} style={{ padding: '10px 0', borderTop: '1px solid var(--border)' }}>
<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 key={f.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '10px 0', borderTop: '1px solid var(--border)', alignItems: 'center' }}>
<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) => {

View File

@ -54,6 +54,15 @@ export default function AdminBlsFoodsPage() {
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 (
@ -91,8 +100,7 @@ export default function AdminBlsFoodsPage() {
{tab === 'manual' && (
<>
<p style={{ fontSize: 13, color: 'var(--text2)', margin: '0 0 10px' }}>
Zentral (für alle Nutzer) und persönlich (nur der jeweilige Nutzer). Zentrale Einträge hier anlegen;
persönliche bleiben beim Owner.
Zentral (für alle Nutzer) und persönlich (Nutzer-eigene). Zeile oder Bearbeiten öffnet den Editor.
</p>
<div style={{ marginBottom: 10 }}>
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>Zentral anlegen</button>
@ -124,13 +132,13 @@ export default function AdminBlsFoodsPage() {
type="button"
className="btn btn-secondary"
style={{ flex: 1, textAlign: 'left' }}
onClick={() => openDetail(r.id)}
onClick={() => openEdit(r)}
>
{r.bls_code ? `${r.bls_code} · ` : ''}{r.name_de}
{r.catalog_kind === 'manual_admin' ? ' · zentral (alle)' : r.catalog_kind === 'manual_user' ? ' · persönlich' : ''}
</button>
{r.catalog_kind === 'manual_admin' && (
<button type="button" className="btn btn-secondary" onClick={() => setEditId(r.id)}>Bearbeiten</button>
{r.catalog_kind && r.catalog_kind !== 'official_bls' && (
<button type="button" className="btn btn-primary" onClick={() => openEdit(r)}>Bearbeiten</button>
)}
</div>
))}

View File

@ -306,7 +306,7 @@ 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, limit) => req(`/admin/bls/foods?${q?('q='+encodeURIComponent(q)+'&'):''}${kind?('kind='+encodeURIComponent(kind)+'&'):''}${limit?('limit='+limit):''}`),
adminBlsFoodDetail: (id) => req(`/admin/bls/foods/${id}`),
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'),