mitai-jinkendo/backend/routers/bls.py
Lars c1873a47b1
All checks were successful
Deploy Development / deploy (push) Successful in 1m4s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
fix: Zuordnungen zuverlässig speichern und Mengen-Varianten zusammenfassen
Mapping bleibt erhalten, auch wenn der Nährwert-Rebuild länger dauert. Offene Liste führt 1 ml/2 ml Olivenöl als ein Lebensmittel. Dazu JSON-Sicherung, eigener Katalogeintrag und Gramm-pro-Einheit.

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

189 lines
5.8 KiB
Python

"""Authenticated catalog search and user-owned foods / mappings."""
from __future__ import annotations
import logging
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
from auth import require_auth
from data_layer.food_mapping import (
apply_mapping_to_items,
apply_quantities_to_items,
clear_mapping_from_items,
normalize_food_name,
suggest_catalog_foods,
upsert_food_mapping,
)
from data_layer.nutrition_items import dates_for_normalized_name, rebuild_daily_nutrients
from db import get_cursor, get_db, r2d
from routers.profiles import get_pid
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/bls", tags=["bls"])
class UserFoodCreate(BaseModel):
name_de: str
name_en: Optional[str] = None
macros_per_100g: Optional[dict] = None
class MappingUpsert(BaseModel):
source_name: str
food_id: str
source_system: str = "fddb"
grams_per_unit: Optional[float] = None
source_unit: Optional[str] = None
def _pid(session: dict, x_profile_id: Optional[str] = None) -> str:
return x_profile_id or session["profile_id"]
def _rebuild_days(cur, profile_id: str, dates: list[str]) -> None:
for d in dates:
rebuild_daily_nutrients(cur, profile_id, d)
@router.get("/foods")
def search_foods(
q: str = "",
limit: int = 20,
session: dict = Depends(require_auth),
):
pid = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
return suggest_catalog_foods(cur, q, pid, limit=min(max(limit, 1), 50))
@router.post("/foods/manual")
def create_user_food(
body: UserFoodCreate,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from routers.admin_bls import _write_manual_macros
import uuid
pid = _pid(session, x_profile_id)
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, owner_profile_id, source, external_key)
VALUES (%s, %s, 'manual_user', %s, 'manual', %s)
RETURNING *
""",
(name, body.name_en, pid, f"man-user-{normalize_food_name(name)[:32]}-{uuid.uuid4().hex[:8]}"),
)
food = r2d(cur.fetchone())
_write_manual_macros(cur, food["id"], body.macros_per_100g)
return food
@router.get("/mappings")
def list_my_mappings(
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
pid = _pid(session, x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT m.id, m.source_name_raw, m.source_name_normalized, m.food_id, m.source,
m.grams_per_unit, m.source_unit,
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
WHERE m.profile_id = %s
ORDER BY m.source_name_normalized
""",
(pid,),
)
return [r2d(r) for r in cur.fetchall()]
@router.post("/mappings")
def upsert_my_mapping(
body: MappingUpsert,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
pid = _pid(session, x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id FROM food_catalog
WHERE id = %s AND is_active = true
AND (owner_profile_id IS NULL OR owner_profile_id = %s)
""",
(body.food_id, pid),
)
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=pid,
source="bulk",
source_system=body.source_system,
grams_per_unit=body.grams_per_unit,
source_unit=body.source_unit,
)
norm = normalize_food_name(body.source_name)
n = apply_mapping_to_items(cur, pid, norm, body.food_id, mid)
apply_quantities_to_items(cur, pid, norm, body.grams_per_unit)
dates = dates_for_normalized_name(cur, pid, norm)
try:
with get_db() as conn:
_rebuild_days(get_cursor(conn), pid, dates)
except Exception:
logger.exception("Nährwert-Rebuild nach Mapping %s fehlgeschlagen", norm)
return {"mapping_id": mid, "items_updated": n, "source_name_normalized": norm}
@router.delete("/mappings/{mapping_id}")
def delete_my_mapping(
mapping_id: int,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
pid = _pid(session, x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT source_name_normalized FROM food_name_mappings
WHERE id = %s AND profile_id = %s
""",
(mapping_id, pid),
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "Mapping nicht gefunden")
norm = row["source_name_normalized"]
dates = dates_for_normalized_name(cur, pid, norm)
clear_mapping_from_items(cur, pid, norm)
cur.execute("DELETE FROM food_name_mappings WHERE id = %s AND profile_id = %s", (mapping_id, pid))
try:
with get_db() as conn:
_rebuild_days(get_cursor(conn), pid, dates)
except Exception:
logger.exception("Nährwert-Rebuild nach Mapping-Löschen fehlgeschlagen")
return {"ok": True}
# keep get_pid imported for consistency with other routers
_ = get_pid