"""Authenticated catalog search and user-owned foods / mappings.""" from __future__ import annotations import logging import threading 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, list_quantity_units, normalize_food_name, upsert_food_mapping, ) from data_layer.food_suggest import ( invalidate_suggest_index, suggest_batch, suggest_catalog_foods_ranked, ) 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 attributes: Optional[dict] = None serving_g: Optional[float] = 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 class SuggestBatchBody(BaseModel): names: list[str] limit: int = 3 class UserAttributeCreate(BaseModel): name_de: str unit: str = "g" attr_key: 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) def _rebuild_days_bg(profile_id: str, dates: list[str], context: str) -> None: if not dates: return try: with get_db() as conn: _rebuild_days(get_cursor(conn), profile_id, dates) except Exception: logger.exception("Nährwert-Rebuild nach %s fehlgeschlagen", context) def _schedule_rebuild(profile_id: str, dates: list[str], context: str) -> None: threading.Thread( target=_rebuild_days_bg, args=(profile_id, list(dates), context), daemon=True, name="nutrition-rebuild", ).start() @router.get("/units") def list_food_units(session: dict = Depends(require_auth)): return list_quantity_units() @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_ranked(cur, q, pid, limit=min(max(limit, 1), 50)) @router.get("/attributes") def list_food_attributes( q: str = "", keys: str = "", limit: int = 40, session: dict = Depends(require_auth), ): from data_layer.food_attributes import list_numeric_attributes key_list = [p.strip() for p in (keys or "").split(",") if p.strip()] with get_db() as conn: return list_numeric_attributes(get_cursor(conn), q, limit=limit, keys=key_list or None) @router.post("/attributes") def create_food_attribute(body: UserAttributeCreate, session: dict = Depends(require_auth)): from data_layer.food_attributes import create_extension_attribute try: with get_db() as conn: return create_extension_attribute( get_cursor(conn), body.name_de, body.unit, body.attr_key ) except ValueError as e: raise HTTPException(400, str(e)) from e @router.post("/foods/suggest-batch") def suggest_foods_batch( body: SuggestBatchBody, session: dict = Depends(require_auth), ): names = [n for n in (body.names or []) if isinstance(n, str)][:80] limit = min(max(body.limit or 3, 1), 5) with get_db() as conn: cur = get_cursor(conn) return suggest_batch(cur, session["profile_id"], names, limit=limit) @router.post("/foods/manual") def create_user_food( body: UserFoodCreate, x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth), ): from data_layer.food_attributes import macros_and_attributes_to_values, write_numeric_attributes import uuid pid = _pid(session, x_profile_id) name = (body.name_de or "").strip() if not name: raise HTTPException(400, "Name fehlt") serving = body.serving_g if body.serving_g and body.serving_g > 0 else None values = macros_and_attributes_to_values(body.macros_per_100g, body.attributes, serving) 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_numeric_attributes(cur, food["id"], values) invalidate_suggest_index(pid) 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, name_de, bls_code, catalog_kind 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), ) food = cur.fetchone() if not food: 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) _schedule_rebuild(pid, dates, f"Mapping {norm}") return { "mapping_id": mid, "items_updated": n, "source_name_normalized": norm, "food_id": body.food_id, "food_name_de": food["name_de"], "bls_code": food.get("bls_code"), "catalog_kind": food.get("catalog_kind"), } @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)) _schedule_rebuild(pid, dates, "Mapping-Löschen") return {"ok": True} # keep get_pid imported for consistency with other routers _ = get_pid