Lebensmittel oder Rezept wählbar; gemappte Zutaten werden übernommen, offene erscheinen in der Liste. Dazu Fettgehalt-Suche, EPA-Stoffe, Rezept-CRUD und Wechsel bestehender Zuordnungen. Co-authored-by: Cursor <cursoragent@cursor.com>
154 lines
5.3 KiB
Python
154 lines
5.3 KiB
Python
"""Admin CRUD for food_name_mappings."""
|
|
from __future__ import annotations
|
|
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from auth import require_admin
|
|
from data_layer.food_mapping import apply_mapping_to_items, upsert_food_mapping
|
|
from data_layer.nutrition_items import dates_for_normalized_name, rebuild_daily_nutrients
|
|
from db import get_cursor, get_db, r2d
|
|
|
|
router = APIRouter(prefix="/api/admin/food-mappings", tags=["admin", "food-mappings"])
|
|
|
|
|
|
class FoodMappingCreate(BaseModel):
|
|
source_name: str
|
|
food_id: str
|
|
profile_id: Optional[str] = None
|
|
source_system: str = "fddb"
|
|
|
|
|
|
class FoodMappingUpdate(BaseModel):
|
|
food_id: str
|
|
grams_per_unit: Optional[float] = None
|
|
source_unit: Optional[str] = None
|
|
|
|
|
|
@router.get("")
|
|
def list_food_mappings(
|
|
profile_id: Optional[str] = None,
|
|
global_only: bool = False,
|
|
session: dict = Depends(require_admin),
|
|
):
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
q = """
|
|
SELECT m.id, m.source_name_raw, m.source_name_normalized, m.food_id,
|
|
m.profile_id, m.source, m.created_at, m.updated_at,
|
|
f.name_de AS food_name_de, f.bls_code, f.catalog_kind
|
|
FROM food_name_mappings m
|
|
JOIN food_catalog f ON f.id = m.food_id
|
|
"""
|
|
conds, params = [], []
|
|
if global_only:
|
|
conds.append("m.profile_id IS NULL")
|
|
elif profile_id:
|
|
conds.append("m.profile_id = %s")
|
|
params.append(profile_id)
|
|
if conds:
|
|
q += " WHERE " + " AND ".join(conds)
|
|
q += " ORDER BY m.source_name_normalized"
|
|
cur.execute(q, params)
|
|
return [r2d(r) for r in cur.fetchall()]
|
|
|
|
|
|
@router.get("/stats/coverage")
|
|
def mapping_coverage(session: dict = Depends(require_admin)):
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
COUNT(*) AS total_items,
|
|
COUNT(food_id) AS mapped_items,
|
|
COUNT(*) - COUNT(food_id) AS unmapped_items,
|
|
COUNT(DISTINCT source_name_normalized) AS unique_names,
|
|
COUNT(DISTINCT CASE WHEN food_id IS NULL THEN source_name_normalized END) AS unmapped_names
|
|
FROM nutrition_items
|
|
"""
|
|
)
|
|
return r2d(cur.fetchone())
|
|
|
|
|
|
@router.post("")
|
|
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")
|
|
mid = upsert_food_mapping(
|
|
cur,
|
|
source_name_raw=body.source_name,
|
|
food_id=body.food_id,
|
|
profile_id=body.profile_id or None,
|
|
source="admin",
|
|
source_system=body.source_system,
|
|
)
|
|
cur.execute(
|
|
"""
|
|
SELECT m.*, f.name_de AS food_name_de, f.bls_code
|
|
FROM food_name_mappings m
|
|
JOIN food_catalog f ON f.id = m.food_id
|
|
WHERE m.id = %s
|
|
""",
|
|
(mid,),
|
|
)
|
|
return r2d(cur.fetchone())
|
|
|
|
|
|
@router.put("/{mapping_id}")
|
|
def update_food_mapping(mapping_id: int, body: FoodMappingUpdate, session: dict = Depends(require_admin)):
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute(
|
|
"""
|
|
SELECT id, source_name_raw, source_name_normalized, profile_id
|
|
FROM food_name_mappings WHERE id = %s
|
|
""",
|
|
(mapping_id,),
|
|
)
|
|
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():
|
|
raise HTTPException(404, "Lebensmittel nicht gefunden")
|
|
cur.execute(
|
|
"""
|
|
UPDATE food_name_mappings
|
|
SET food_id = %s, grams_per_unit = COALESCE(%s, grams_per_unit),
|
|
source_unit = COALESCE(%s, source_unit), source = 'admin', updated_at = NOW()
|
|
WHERE id = %s
|
|
""",
|
|
(body.food_id, body.grams_per_unit, body.source_unit, mapping_id),
|
|
)
|
|
if row.get("profile_id"):
|
|
apply_mapping_to_items(cur, str(row["profile_id"]), row["source_name_normalized"], body.food_id, mapping_id)
|
|
dates = dates_for_normalized_name(cur, str(row["profile_id"]), row["source_name_normalized"])
|
|
for d in dates:
|
|
rebuild_daily_nutrients(cur, str(row["profile_id"]), d)
|
|
cur.execute(
|
|
"""
|
|
SELECT m.*, f.name_de AS food_name_de, f.bls_code
|
|
FROM food_name_mappings m
|
|
JOIN food_catalog f ON f.id = m.food_id
|
|
WHERE m.id = %s
|
|
""",
|
|
(mapping_id,),
|
|
)
|
|
return r2d(cur.fetchone())
|
|
|
|
|
|
@router.delete("/{mapping_id}")
|
|
def delete_food_mapping(mapping_id: int, session: dict = Depends(require_admin)):
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute("DELETE FROM food_name_mappings WHERE id = %s RETURNING id", (mapping_id,))
|
|
if not cur.fetchone():
|
|
raise HTTPException(404, "Mapping nicht gefunden")
|
|
return {"ok": True}
|