Roh-Kombinationen statt Rezept-Sprache; Zutaten suchen, EL/TL/Prise auf Gramm. Gekochte Gerichte und Ausbeute bleiben Tandoor. Co-authored-by: Cursor <cursoragent@cursor.com>
253 lines
7.7 KiB
Python
253 lines
7.7 KiB
Python
"""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
|
|
|
|
|
|
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 = "",
|
|
limit: int = 40,
|
|
session: dict = Depends(require_auth),
|
|
):
|
|
from data_layer.food_attributes import list_numeric_attributes
|
|
with get_db() as conn:
|
|
return list_numeric_attributes(get_cursor(conn), q, limit=limit)
|
|
|
|
|
|
@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
|