mitai-jinkendo/backend/routers/admin_bls.py
Lars a07a668de2
All checks were successful
Deploy Development / deploy (push) Successful in 1m10s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 24s
fix: BLS-Datenimport als Hintergrundjob gegen HTTP 504
Prüfung und Apply laufen serverseitig, die UI pollt nur noch den Status.

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

283 lines
9.4 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.jobs import create_and_check, get_job, start_apply
from bls.parser import parse_components_xlsx, parse_foods_xlsx
from db import get_cursor, get_db, r2d
MAX_IMPORT_BYTES = 50 * 1024 * 1024
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.post("/import/jobs")
async def start_import_job(
kind: str,
file: UploadFile = File(...),
session: dict = Depends(require_admin),
):
if kind not in ("components", "foods"):
raise HTTPException(400, "kind muss components oder foods sein")
raw = await file.read()
if not raw:
raise HTTPException(400, "Leere Datei")
if len(raw) > MAX_IMPORT_BYTES:
raise HTTPException(400, "Datei größer als 50 MB")
job_id = create_and_check(kind, raw)
return {"id": job_id, "status": "checking"}
@router.get("/import/jobs/{job_id}")
def import_job_status(job_id: str, session: dict = Depends(require_admin)):
job = get_job(job_id)
if not job:
raise HTTPException(404, "Import-Job nicht gefunden")
return job
@router.post("/import/jobs/{job_id}/apply")
def apply_import_job(job_id: str, session: dict = Depends(require_admin)):
try:
start_apply(job_id)
except KeyError:
raise HTTPException(404, "Import-Job nicht gefunden") from None
except ValueError as e:
raise HTTPException(409, str(e)) from e
return get_job(job_id)
@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])),
)