Datei-Button volle Breite, Import als Aktionsbutton, Batch-Upsert statt Einzel-INSERTs. Co-authored-by: Cursor <cursoragent@cursor.com>
244 lines
8.2 KiB
Python
244 lines
8.2 KiB
Python
"""Admin BLS catalog import and attribute/food maintenance."""
|
|
from __future__ import annotations
|
|
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
|
from fastapi.concurrency import run_in_threadpool
|
|
from pydantic import BaseModel
|
|
|
|
from auth import require_admin
|
|
from bls.import_service import upsert_attributes, upsert_foods
|
|
from bls.parser import parse_components_xlsx, parse_foods_xlsx
|
|
from db import get_cursor, get_db, r2d
|
|
|
|
router = APIRouter(prefix="/api/admin/bls", tags=["admin", "bls"])
|
|
|
|
|
|
class AttributeCreate(BaseModel):
|
|
attr_key: str
|
|
name_de: str
|
|
name_en: Optional[str] = None
|
|
unit: Optional[str] = None
|
|
category: Optional[str] = None
|
|
data_type: str = "num_per_100g"
|
|
enum_values: Optional[list] = None
|
|
|
|
|
|
class ManualFoodCreate(BaseModel):
|
|
name_de: str
|
|
name_en: Optional[str] = None
|
|
macros_per_100g: Optional[dict] = None
|
|
|
|
|
|
@router.get("/status")
|
|
def bls_status(session: dict = Depends(require_admin)):
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute("SELECT COUNT(*) AS n FROM food_catalog WHERE catalog_kind = 'official_bls'")
|
|
foods = cur.fetchone()["n"]
|
|
cur.execute("SELECT COUNT(*) AS n FROM food_attributes WHERE origin = 'official_bls'")
|
|
attrs = cur.fetchone()["n"]
|
|
cur.execute("SELECT COUNT(*) AS n FROM food_catalog WHERE catalog_kind <> 'official_bls'")
|
|
manual = cur.fetchone()["n"]
|
|
cur.execute("SELECT MAX(updated_at) AS last_updated FROM food_catalog WHERE catalog_kind = 'official_bls'")
|
|
last = cur.fetchone()["last_updated"]
|
|
return {
|
|
"official_foods": foods,
|
|
"official_attributes": attrs,
|
|
"manual_foods": manual,
|
|
"last_updated": last,
|
|
"source": "Max Rubner-Institut, BLS 4.0 (frei verfügbar)",
|
|
}
|
|
|
|
|
|
@router.post("/import/components")
|
|
async def import_components(
|
|
file: UploadFile = File(...),
|
|
dry_run: bool = True,
|
|
session: dict = Depends(require_admin),
|
|
):
|
|
raw = await file.read()
|
|
if not raw:
|
|
raise HTTPException(400, "Leere Datei")
|
|
try:
|
|
attrs = await run_in_threadpool(parse_components_xlsx, raw)
|
|
except Exception as e:
|
|
raise HTTPException(400, f"Components-Datei unlesbar: {e}") from e
|
|
if dry_run:
|
|
return {"dry_run": True, "attributes": len(attrs), "sample": attrs[:8]}
|
|
|
|
def apply():
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
return upsert_attributes(cur, attrs)
|
|
|
|
stats = await run_in_threadpool(apply)
|
|
return {"dry_run": False, **stats}
|
|
|
|
|
|
@router.post("/import/foods")
|
|
async def import_foods(
|
|
file: UploadFile = File(...),
|
|
dry_run: bool = True,
|
|
session: dict = Depends(require_admin),
|
|
):
|
|
raw = await file.read()
|
|
if not raw:
|
|
raise HTTPException(400, "Leere Datei")
|
|
try:
|
|
parsed = await run_in_threadpool(parse_foods_xlsx, raw)
|
|
except Exception as e:
|
|
raise HTTPException(400, f"Datendatei unlesbar: {e}") from e
|
|
foods = parsed["foods"]
|
|
if dry_run:
|
|
return {
|
|
"dry_run": True,
|
|
"foods": len(foods),
|
|
"attribute_columns": len(parsed["attribute_headers"]),
|
|
"sample": [
|
|
{"bls_code": f["bls_code"], "name_de": f["name_de"]}
|
|
for f in foods[:8]
|
|
],
|
|
}
|
|
|
|
def apply():
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
return upsert_foods(cur, foods)
|
|
|
|
stats = await run_in_threadpool(apply)
|
|
return {"dry_run": False, **stats}
|
|
|
|
|
|
@router.get("/foods")
|
|
def admin_list_foods(
|
|
q: Optional[str] = None,
|
|
kind: Optional[str] = None,
|
|
limit: int = 50,
|
|
session: dict = Depends(require_admin),
|
|
):
|
|
limit = min(max(limit, 1), 200)
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
conds = ["is_active = true"]
|
|
params: list = []
|
|
if kind:
|
|
conds.append("catalog_kind = %s")
|
|
params.append(kind)
|
|
if q:
|
|
conds.append("(name_de ILIKE %s OR COALESCE(name_en,'') ILIKE %s OR COALESCE(bls_code,'') ILIKE %s)")
|
|
like = f"%{q}%"
|
|
params.extend([like, like, like])
|
|
params.append(limit)
|
|
cur.execute(
|
|
f"""
|
|
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group, bls_version
|
|
FROM food_catalog
|
|
WHERE {' AND '.join(conds)}
|
|
ORDER BY name_de
|
|
LIMIT %s
|
|
""",
|
|
params,
|
|
)
|
|
return [r2d(r) for r in cur.fetchall()]
|
|
|
|
|
|
@router.get("/foods/{food_id}")
|
|
def admin_food_detail(food_id: str, session: dict = Depends(require_admin)):
|
|
from data_layer.nutrition_items import resolve_food_attributes
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute("SELECT * FROM food_catalog WHERE id = %s", (food_id,))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Lebensmittel nicht gefunden")
|
|
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_mapping import normalize_food_name
|
|
|
|
name = (body.name_de or "").strip()
|
|
if not name:
|
|
raise HTTPException(400, "Name fehlt")
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO food_catalog (name_de, name_en, catalog_kind, source, external_key)
|
|
VALUES (%s, %s, 'manual_admin', 'manual', %s)
|
|
RETURNING *
|
|
""",
|
|
(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)
|
|
return food
|
|
|
|
|
|
@router.get("/attributes")
|
|
def admin_list_attributes(session: dict = Depends(require_admin)):
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute(
|
|
"""
|
|
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()]
|
|
|
|
|
|
@router.post("/attributes")
|
|
def admin_create_attribute(body: AttributeCreate, session: dict = Depends(require_admin)):
|
|
key = body.attr_key.strip().upper().replace(" ", "_")
|
|
if not key:
|
|
raise HTTPException(400, "attr_key fehlt")
|
|
if body.data_type not in ("num_per_100g", "boolean", "text", "enum"):
|
|
raise HTTPException(400, "Ungültiger data_type")
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
try:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO food_attributes
|
|
(attr_key, name_de, name_en, unit, category, data_type, enum_values, origin, sort_order)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, 'extension', 9000)
|
|
RETURNING *
|
|
""",
|
|
(
|
|
key, body.name_de, body.name_en, body.unit, body.category,
|
|
body.data_type, None if not body.enum_values else body.enum_values,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
raise HTTPException(409, f"Attribut existiert bereits oder ist ungültig: {e}") from e
|
|
return r2d(cur.fetchone())
|
|
|
|
|
|
def _write_manual_macros(cur, food_id: str, macros: dict | None) -> None:
|
|
if not macros:
|
|
return
|
|
mapping = {"kcal": "ENERCC", "protein_g": "PROT625", "fat_g": "FAT", "carbs_g": "CHO"}
|
|
for field, key in mapping.items():
|
|
if field not in macros or macros[field] is None:
|
|
continue
|
|
cur.execute("SELECT id FROM food_attributes WHERE attr_key = %s", (key,))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
continue
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO food_attribute_values (food_id, attribute_id, value_num, is_trace)
|
|
VALUES (%s, %s, %s, false)
|
|
ON CONFLICT (food_id, attribute_id) DO UPDATE SET value_num = EXCLUDED.value_num, updated_at = NOW()
|
|
""",
|
|
(food_id, row["id"], float(macros[field])),
|
|
)
|