Katalog, lernendes Mapping ohne KI, optionale Items und Import-Policy. Playwright-Smoke und Issue-Audit um Ernährung/Zuordnen/API ergänzt. Co-authored-by: Cursor <cursoragent@cursor.com>
479 lines
18 KiB
Python
479 lines
18 KiB
Python
"""
|
|
Nutrition Tracking Endpoints for Mitai Jinkendo
|
|
|
|
Handles nutrition data, FDDB CSV import, correlations, and weekly aggregates.
|
|
"""
|
|
import csv
|
|
import io
|
|
import uuid
|
|
import logging
|
|
from typing import Optional
|
|
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
|
|
|
from db import get_db, get_cursor, r2d
|
|
from auth import require_auth, check_feature_access, increment_feature_usage
|
|
from routers.profiles import get_pid
|
|
from feature_logger import log_feature_usage
|
|
from data_layer.nutrition_body_merge import build_merged_daily_nutrition_body_rows
|
|
|
|
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ── Helper ────────────────────────────────────────────────────────────────────
|
|
def _pf(s):
|
|
"""Parse float from string (handles comma decimal separator)."""
|
|
try: return float(str(s).replace(',','.').strip())
|
|
except: return 0.0
|
|
|
|
|
|
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
|
@router.post("/import-csv")
|
|
async def import_nutrition_csv(
|
|
file: UploadFile = File(...),
|
|
overwrite_existing: bool = False,
|
|
x_profile_id: Optional[str] = Header(default=None),
|
|
session: dict = Depends(require_auth),
|
|
):
|
|
"""Import FDDB nutrition CSV (optional item persist + conflict policy)."""
|
|
pid = get_pid(x_profile_id)
|
|
|
|
# Phase 4: Check feature access and ENFORCE
|
|
# Note: CSV import can create many entries - we check once before import
|
|
access = check_feature_access(pid, 'nutrition_entries')
|
|
log_feature_usage(pid, 'nutrition_entries', access, 'import_csv')
|
|
|
|
if not access['allowed']:
|
|
logger.warning(
|
|
f"[FEATURE-LIMIT] User {pid} blocked: "
|
|
f"nutrition_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
|
)
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"Limit erreicht: Du hast das Kontingent für Ernährungseinträge überschritten ({access['used']}/{access['limit']}). "
|
|
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
|
)
|
|
|
|
raw = await file.read()
|
|
try: text = raw.decode('utf-8')
|
|
except: text = raw.decode('latin-1')
|
|
if text.startswith('\ufeff'): text = text[1:]
|
|
if not text.strip(): raise HTTPException(400,"Leere Datei")
|
|
from data_layer.nutrition_items import get_import_policy, replace_csv_items_for_dates
|
|
|
|
overwrite = bool(overwrite_existing)
|
|
reader = csv.DictReader(io.StringIO(text), delimiter=';')
|
|
item_rows = []
|
|
days: dict = {}
|
|
count = 0
|
|
for row in reader:
|
|
rd = row.get('datum_tag_monat_jahr_stunde_minute','').strip().strip('"')
|
|
if not rd: continue
|
|
try:
|
|
parts = rd.split(' ')
|
|
p = parts[0].split('.')
|
|
iso = f"{p[2]}-{p[1]}-{p[0]}"
|
|
logged_at = None
|
|
if len(parts) > 1:
|
|
try:
|
|
logged_at = datetime.strptime(rd.strip(), '%d.%m.%Y %H:%M')
|
|
except ValueError:
|
|
logged_at = None
|
|
except Exception:
|
|
continue
|
|
kcal = _pf(row.get('kj', 0)) / 4.184
|
|
fat = _pf(row.get('fett_g', 0))
|
|
carbs = _pf(row.get('kh_g', 0))
|
|
prot = _pf(row.get('protein_g', 0))
|
|
days.setdefault(iso, {'kcal': 0, 'fat_g': 0, 'carbs_g': 0, 'protein_g': 0})
|
|
days[iso]['kcal'] += kcal
|
|
days[iso]['fat_g'] += fat
|
|
days[iso]['carbs_g'] += carbs
|
|
days[iso]['protein_g'] += prot
|
|
name = (row.get('bezeichnung') or '').strip().strip('"')
|
|
if name:
|
|
item_rows.append({
|
|
"date": iso,
|
|
"logged_at": logged_at,
|
|
"food_name": name,
|
|
"quantity_raw": (row.get('menge') or '').strip() or None,
|
|
"kcal": kcal,
|
|
"protein_g": prot,
|
|
"fat_g": fat,
|
|
"carbs_g": carbs,
|
|
})
|
|
count += 1
|
|
inserted = 0
|
|
new_entries = 0
|
|
ingest_result = {"conflicts": [], "items_written": 0, "policy": "prompt"}
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
policy = get_import_policy(cur, pid)
|
|
override = "overwrite_catalog" if overwrite else None
|
|
if item_rows:
|
|
ingest_result = replace_csv_items_for_dates(
|
|
cur, pid, item_rows, policy=policy, policy_override=override,
|
|
)
|
|
new_entries = ingest_result.get("new_log_days") or 0
|
|
inserted = ingest_result.get("days_written") or 0
|
|
else:
|
|
for iso, vals in days.items():
|
|
kcal = round(vals['kcal'], 1)
|
|
fat = round(vals['fat_g'], 1)
|
|
carbs = round(vals['carbs_g'], 1)
|
|
prot = round(vals['protein_g'], 1)
|
|
cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s", (pid, iso))
|
|
is_new = not cur.fetchone()
|
|
if not is_new:
|
|
if policy in ("overwrite_catalog", "overwrite_fddb") or overwrite:
|
|
cur.execute(
|
|
"UPDATE nutrition_log SET kcal=%s,protein_g=%s,fat_g=%s,carbs_g=%s,source='csv',macro_origin='fddb' WHERE profile_id=%s AND date=%s",
|
|
(kcal, prot, fat, carbs, pid, iso),
|
|
)
|
|
else:
|
|
cur.execute(
|
|
"INSERT INTO nutrition_log (id,profile_id,date,kcal,protein_g,fat_g,carbs_g,source,macro_origin,created) VALUES (%s,%s,%s,%s,%s,%s,%s,'csv','fddb',CURRENT_TIMESTAMP)",
|
|
(str(uuid.uuid4()), pid, iso, kcal, prot, fat, carbs),
|
|
)
|
|
new_entries += 1
|
|
inserted += 1
|
|
|
|
for _ in range(new_entries):
|
|
increment_feature_usage(pid, 'nutrition_entries')
|
|
|
|
return {
|
|
"rows_parsed": count,
|
|
"days_imported": inserted,
|
|
"new_entries": new_entries,
|
|
"items_written": ingest_result.get("items_written", 0),
|
|
"conflicts": ingest_result.get("conflicts") or [],
|
|
"policy": ingest_result.get("policy", "prompt"),
|
|
"date_range": {"from": min(days) if days else None, "to": max(days) if days else None},
|
|
}
|
|
|
|
|
|
@router.post("")
|
|
def create_nutrition(date: str, kcal: float, protein_g: float, fat_g: float, carbs_g: float,
|
|
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
"""Create or update nutrition entry for a specific date."""
|
|
pid = get_pid(x_profile_id)
|
|
|
|
# Validate date format
|
|
try:
|
|
datetime.strptime(date, '%Y-%m-%d')
|
|
except ValueError:
|
|
raise HTTPException(400, "Ungültiges Datumsformat. Erwartet: YYYY-MM-DD")
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
# Check if entry exists
|
|
cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s", (pid, date))
|
|
existing = cur.fetchone()
|
|
|
|
if existing:
|
|
# UPDATE existing entry
|
|
cur.execute("""
|
|
UPDATE nutrition_log
|
|
SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source='manual', macro_origin='manual'
|
|
WHERE id=%s AND profile_id=%s
|
|
""", (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), existing['id'], pid))
|
|
return {"success": True, "mode": "updated", "id": existing['id']}
|
|
else:
|
|
# Phase 4: Check feature access before INSERT
|
|
access = check_feature_access(pid, 'nutrition_entries')
|
|
log_feature_usage(pid, 'nutrition_entries', access, 'create')
|
|
|
|
if not access['allowed']:
|
|
logger.warning(
|
|
f"[FEATURE-LIMIT] User {pid} blocked: "
|
|
f"nutrition_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
|
)
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"Limit erreicht: Du hast das Kontingent für Ernährungseinträge überschritten ({access['used']}/{access['limit']}). "
|
|
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
|
)
|
|
|
|
# INSERT new entry
|
|
new_id = str(uuid.uuid4())
|
|
cur.execute("""
|
|
INSERT INTO nutrition_log (id, profile_id, date, kcal, protein_g, fat_g, carbs_g, source, macro_origin, created)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, 'manual', 'manual', CURRENT_TIMESTAMP)
|
|
""", (new_id, pid, date, round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1)))
|
|
|
|
# Phase 2: Increment usage counter
|
|
increment_feature_usage(pid, 'nutrition_entries')
|
|
|
|
return {"success": True, "mode": "created", "id": new_id}
|
|
|
|
|
|
@router.get("")
|
|
def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
"""Get nutrition entries for current profile."""
|
|
pid = get_pid(x_profile_id)
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute(
|
|
"""
|
|
SELECT n.*, m.mark_type, m.note AS mark_note
|
|
FROM nutrition_log n
|
|
LEFT JOIN nutrition_day_marks m
|
|
ON m.profile_id = n.profile_id AND m.date = n.date
|
|
WHERE n.profile_id=%s
|
|
ORDER BY n.date DESC
|
|
LIMIT %s
|
|
""",
|
|
(pid, limit),
|
|
)
|
|
return [r2d(r) for r in cur.fetchall()]
|
|
|
|
|
|
@router.get("/by-date/{date}")
|
|
def get_nutrition_by_date(date: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
"""Get nutrition entry for a specific date."""
|
|
pid = get_pid(x_profile_id)
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s AND date=%s", (pid, date))
|
|
row = cur.fetchone()
|
|
return r2d(row) if row else None
|
|
|
|
|
|
@router.get("/correlations")
|
|
def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
"""Get nutrition data correlated with weight and body fat (Layer 1 Merge, siehe nutrition_body_merge)."""
|
|
pid = get_pid(x_profile_id)
|
|
return build_merged_daily_nutrition_body_rows(pid)
|
|
|
|
|
|
@router.get("/weekly")
|
|
def nutrition_weekly(weeks: int=16, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
"""Get nutrition data aggregated by week."""
|
|
pid = get_pid(x_profile_id)
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s",(pid,weeks*7))
|
|
rows=[r2d(r) for r in cur.fetchall()]
|
|
if not rows: return []
|
|
wm={}
|
|
for d in rows:
|
|
# Handle both datetime.date objects (from DB) and strings
|
|
date_obj = d['date'] if hasattr(d['date'], 'strftime') else datetime.strptime(d['date'],'%Y-%m-%d')
|
|
wk = date_obj.strftime('%Y-W%V')
|
|
wm.setdefault(wk,[]).append(d)
|
|
result=[]
|
|
for wk in sorted(wm):
|
|
en=wm[wk]; n=len(en)
|
|
def avg(k): return round(sum(float(e.get(k) or 0) for e in en)/n,1)
|
|
result.append({'week':wk,'days':n,'kcal':avg('kcal'),'protein_g':avg('protein_g'),'fat_g':avg('fat_g'),'carbs_g':avg('carbs_g')})
|
|
return result
|
|
|
|
|
|
@router.get("/import-history")
|
|
def import_history(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
"""Get import history by grouping entries by created timestamp."""
|
|
pid = get_pid(x_profile_id)
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute("""
|
|
SELECT
|
|
DATE(created) as import_date,
|
|
COUNT(*) as count,
|
|
MIN(date) as date_from,
|
|
MAX(date) as date_to,
|
|
MAX(created) as last_created
|
|
FROM nutrition_log
|
|
WHERE profile_id=%s AND source='csv'
|
|
GROUP BY DATE(created)
|
|
ORDER BY DATE(created) DESC
|
|
""", (pid,))
|
|
return [r2d(r) for r in cur.fetchall()]
|
|
|
|
|
|
@router.get("/items")
|
|
def list_nutrition_items(
|
|
date: Optional[str] = None,
|
|
limit: int = 200,
|
|
x_profile_id: Optional[str] = Header(default=None),
|
|
session: dict = Depends(require_auth),
|
|
):
|
|
pid = get_pid(x_profile_id)
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
if date:
|
|
cur.execute(
|
|
"""
|
|
SELECT i.*, f.name_de AS food_name_de, f.bls_code, f.catalog_kind
|
|
FROM nutrition_items i
|
|
LEFT JOIN food_catalog f ON f.id = i.food_id
|
|
WHERE i.profile_id=%s AND i.date=%s
|
|
ORDER BY i.logged_at NULLS LAST, i.source_name_raw
|
|
""",
|
|
(pid, date),
|
|
)
|
|
else:
|
|
cur.execute(
|
|
"""
|
|
SELECT i.*, f.name_de AS food_name_de, f.bls_code, f.catalog_kind
|
|
FROM nutrition_items i
|
|
LEFT JOIN food_catalog f ON f.id = i.food_id
|
|
WHERE i.profile_id=%s
|
|
ORDER BY i.date DESC, i.logged_at NULLS LAST
|
|
LIMIT %s
|
|
""",
|
|
(pid, min(limit, 500)),
|
|
)
|
|
return [r2d(r) for r in cur.fetchall()]
|
|
|
|
|
|
@router.get("/unmapped")
|
|
def list_unmapped_foods(
|
|
x_profile_id: Optional[str] = Header(default=None),
|
|
session: dict = Depends(require_auth),
|
|
):
|
|
pid = get_pid(x_profile_id)
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute(
|
|
"""
|
|
SELECT source_name_raw, source_name_normalized,
|
|
COUNT(*) AS count, MIN(date) AS first_date, MAX(date) AS last_date
|
|
FROM nutrition_items
|
|
WHERE profile_id=%s AND food_id IS NULL
|
|
GROUP BY source_name_raw, source_name_normalized
|
|
ORDER BY count DESC, source_name_normalized
|
|
""",
|
|
(pid,),
|
|
)
|
|
return [r2d(r) for r in cur.fetchall()]
|
|
|
|
|
|
@router.post("/import-conflicts/resolve")
|
|
def resolve_import_conflicts(
|
|
body: dict,
|
|
x_profile_id: Optional[str] = Header(default=None),
|
|
session: dict = Depends(require_auth),
|
|
):
|
|
from data_layer.nutrition_items import (
|
|
apply_nutrition_day_macros,
|
|
compute_day_macro_sums,
|
|
resolve_choice_macros,
|
|
)
|
|
|
|
pid = get_pid(x_profile_id)
|
|
decisions = body.get("decisions") or []
|
|
applied = 0
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
for dec in decisions:
|
|
day = dec.get("date")
|
|
choice = dec.get("choice")
|
|
if not day or not choice:
|
|
continue
|
|
sums = compute_day_macro_sums(cur, pid, day)
|
|
if not sums.get("existing") and choice == "existing":
|
|
continue
|
|
macros, origin = resolve_choice_macros(sums, choice)
|
|
apply_nutrition_day_macros(
|
|
cur, pid, day, macros, macro_origin=origin, source="csv", confirm=True,
|
|
)
|
|
applied += 1
|
|
return {"applied": applied}
|
|
|
|
|
|
@router.put("/days/{day}/mark")
|
|
def upsert_day_mark(
|
|
day: str,
|
|
body: dict,
|
|
x_profile_id: Optional[str] = Header(default=None),
|
|
session: dict = Depends(require_auth),
|
|
):
|
|
pid = get_pid(x_profile_id)
|
|
mark_type = (body or {}).get("mark_type")
|
|
note = (body or {}).get("note")
|
|
if mark_type not in ("fasting", "incomplete"):
|
|
raise HTTPException(400, "mark_type muss fasting oder incomplete sein")
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO nutrition_day_marks (profile_id, date, mark_type, note, source, updated_at)
|
|
VALUES (%s, %s, %s, %s, 'manual', NOW())
|
|
ON CONFLICT (profile_id, date)
|
|
DO UPDATE SET mark_type = EXCLUDED.mark_type, note = EXCLUDED.note, updated_at = NOW()
|
|
RETURNING *
|
|
""",
|
|
(pid, day, mark_type, note),
|
|
)
|
|
return r2d(cur.fetchone())
|
|
|
|
|
|
@router.delete("/days/{day}/mark")
|
|
def delete_day_mark(
|
|
day: str,
|
|
x_profile_id: Optional[str] = Header(default=None),
|
|
session: dict = Depends(require_auth),
|
|
):
|
|
pid = get_pid(x_profile_id)
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute(
|
|
"DELETE FROM nutrition_day_marks WHERE profile_id=%s AND date=%s",
|
|
(pid, day),
|
|
)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/marks")
|
|
def list_day_marks(
|
|
x_profile_id: Optional[str] = Header(default=None),
|
|
session: dict = Depends(require_auth),
|
|
):
|
|
pid = get_pid(x_profile_id)
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute(
|
|
"SELECT * FROM nutrition_day_marks WHERE profile_id=%s ORDER BY date DESC",
|
|
(pid,),
|
|
)
|
|
return [r2d(r) for r in cur.fetchall()]
|
|
|
|
|
|
@router.put("/{entry_id}")
|
|
def update_nutrition(entry_id: str, kcal: float, protein_g: float, fat_g: float, carbs_g: float,
|
|
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
"""Update nutrition entry macros."""
|
|
pid = get_pid(x_profile_id)
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
# Verify ownership
|
|
cur.execute("SELECT id FROM nutrition_log WHERE id=%s AND profile_id=%s", (entry_id, pid))
|
|
if not cur.fetchone():
|
|
raise HTTPException(404, "Eintrag nicht gefunden")
|
|
|
|
cur.execute("""
|
|
UPDATE nutrition_log
|
|
SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source='manual', macro_origin='manual'
|
|
WHERE id=%s AND profile_id=%s
|
|
""", (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), entry_id, pid))
|
|
|
|
return {"success": True}
|
|
|
|
|
|
@router.delete("/{entry_id}")
|
|
def delete_nutrition(entry_id: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
"""Delete nutrition entry."""
|
|
pid = get_pid(x_profile_id)
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
# Verify ownership
|
|
cur.execute("SELECT id FROM nutrition_log WHERE id=%s AND profile_id=%s", (entry_id, pid))
|
|
if not cur.fetchone():
|
|
raise HTTPException(404, "Eintrag nicht gefunden")
|
|
|
|
cur.execute("DELETE FROM nutrition_log WHERE id=%s AND profile_id=%s", (entry_id, pid))
|
|
|
|
return {"success": True}
|