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} _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: def visibility_of(food: dict[str, Any] | None) -> str:
if not food: if not food:
return VIS_BLS return VIS_BLS

View File

@ -188,7 +188,7 @@ def admin_list_foods(
@router.get("/foods/{food_id}") @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 from data_layer.nutrition_items import resolve_food_attributes
with get_db() as conn: with get_db() as conn:
@ -197,6 +197,13 @@ def admin_food_detail(food_id: str, session: dict = Depends(require_admin)):
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
raise HTTPException(404, "Lebensmittel nicht gefunden") 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) attrs = resolve_food_attributes(cur, food_id)
return {**r2d(row), "attributes": attrs} 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 SELECT id, name_de, name_en, catalog_kind, owner_profile_id
FROM food_catalog 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_id,),
) )
food = cur.fetchone() food = cur.fetchone()
if not food: if not food:
raise HTTPException(404, "Zentrales Lebensmittel nicht gefunden") raise HTTPException(404, "Manuelles Lebensmittel nicht gefunden")
cur.execute( cur.execute(
""" """
UPDATE food_catalog UPDATE food_catalog

View File

@ -238,8 +238,8 @@ def get_food_detail(food_id: str, session: dict = Depends(require_auth)):
if not food: if not food:
raise HTTPException(404, "Lebensmittel nicht gefunden") raise HTTPException(404, "Lebensmittel nicht gefunden")
food = dict(food) 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, same_id
from data_layer.food_visibility import attach_visibility 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)) 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_global_mapping_target,
is_visible_to, is_visible_to,
require_mapping_target, require_mapping_target,
same_id,
visibility_of, visibility_of,
visibility_rank, visibility_rank,
) )
def test_same_id_ignores_dashes_and_case():
assert same_id("AaBbCcDd-0000-0000-0000-000000000001", "aabbccdd000000000000000000000001")
def test_visibility_scopes(): def test_visibility_scopes():
assert visibility_of({"catalog_kind": "official_bls"}) == "bls" 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_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 "activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
"nutrition": "1.3.7", # Sichtbarkeit persönlich vs. zentral im Mapping "nutrition": "1.3.7", # Sichtbarkeit persönlich vs. zentral im Mapping
"tandoor": "1.0.1", # Router-Import ohne future-annotations (Backend startet) "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", "photos": "1.0.0",
"insights": "1.3.0", "insights": "1.3.0",

View File

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

View File

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import FoodEditDialog from './FoodEditDialog' import FoodEditDialog from './FoodEditDialog'
import { foodVisibilitySuffix } from '../utils/foodVisibility' import { foodVisibilitySuffix } from '../utils/foodVisibility'
@ -9,6 +10,7 @@ function filterByName(list, term) {
} }
export default function MyFoodsPanel() { export default function MyFoodsPanel() {
const { isAdmin } = useAuth()
const [foods, setFoods] = useState([]) const [foods, setFoods] = useState([])
const [shared, setShared] = useState([]) const [shared, setShared] = useState([])
const [q, setQ] = useState('') const [q, setQ] = useState('')
@ -17,6 +19,7 @@ export default function MyFoodsPanel() {
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [notice, setNotice] = useState(null) const [notice, setNotice] = useState(null)
const [editId, setEditId] = useState(null) const [editId, setEditId] = useState(null)
const [editMode, setEditMode] = useState('user')
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const load = async () => { const load = async () => {
@ -80,11 +83,15 @@ export default function MyFoodsPanel() {
)} )}
{shownMine.map((f) => ( {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 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={{ fontWeight: 600 }}>{f.name_de}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>nur du</div> <div style={{ fontSize: 12, color: 'var(--text3)' }}>nur du</div>
</div> </button>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => setEditId(f.id)}>Bearbeiten</button> <button type="button" className="btn btn-primary" style={{ fontSize: 12 }} onClick={() => { setEditMode('user'); setEditId(f.id) }}>Bearbeiten</button>
</div> </div>
))} ))}
{visible < filteredMine.length && ( {visible < filteredMine.length && (
@ -101,14 +108,20 @@ export default function MyFoodsPanel() {
<p className="muted">Kein zentraler Treffer für „{q.trim()}“.</p> <p className="muted">Kein zentraler Treffer für „{q.trim()}“.</p>
)} )}
{filteredShared.map((f) => ( {filteredShared.map((f) => (
<div key={f.id} style={{ padding: '10px 0', borderTop: '1px solid var(--border)' }}> <div key={f.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '10px 0', borderTop: '1px solid var(--border)', alignItems: 'center' }}>
<div style={{ fontWeight: 600 }}>{f.name_de}</div> <div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>für alle Nutzer{foodVisibilitySuffix(f.catalog_kind, f.visibility)}</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> </div>
))} ))}
{(creating || editId) && ( {(creating || editId) && (
<FoodEditDialog <FoodEditDialog
mode={creating ? 'user' : editMode}
foodId={creating ? null : editId} foodId={creating ? null : editId}
onClose={() => { setCreating(false); setEditId(null) }} onClose={() => { setCreating(false); setEditId(null) }}
onSaved={(res) => { onSaved={(res) => {

View File

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

View File

@ -306,7 +306,7 @@ export const api = {
adminBlsImportJob: (id) => req(`/admin/bls/import/jobs/${id}`), adminBlsImportJob: (id) => req(`/admin/bls/import/jobs/${id}`),
adminBlsImportApply: (id) => req(`/admin/bls/import/jobs/${id}/apply`, {method:'POST'}), 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):''}`), 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)), adminCreateManualFood: (d) => req('/admin/bls/foods/manual', json(d)),
adminUpdateManualFood: (id, d) => req(`/admin/bls/foods/manual/${encodeURIComponent(id)}`, jput(d)), adminUpdateManualFood: (id, d) => req(`/admin/bls/foods/manual/${encodeURIComponent(id)}`, jput(d)),
adminBlsAttributes: () => req('/admin/bls/attributes'), adminBlsAttributes: () => req('/admin/bls/attributes'),