mitai-jinkendo/backend/routers/admin_bls.py
Lars 919c77fcf8
Some checks failed
Deploy Development / deploy (push) Failing after 52s
Build Test / pytest-backend (push) Successful in 3s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Failing after 9s
feat: BLS-Stammdaten, FDDB-Mapping und Item-Tagebuch (#106)
Katalog, lernendes Mapping ohne KI, optionale Items und Import-Policy.
Playwright-Smoke und Issue-Audit um Ernährung/Zuordnen/API ergänzt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 14:35:57 +02:00

235 lines
7.9 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 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 = 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]}
with get_db() as conn:
cur = get_cursor(conn)
stats = upsert_attributes(cur, attrs)
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 = 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]
],
}
with get_db() as conn:
cur = get_cursor(conn)
stats = upsert_foods(cur, foods)
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])),
)