"""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])), )