""" 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), ) rows = [r2d(r) for r in cur.fetchall()] dates = [r["date"] for r in rows if r.get("date") is not None] counts = {} if dates: cur.execute( """ SELECT date, COUNT(*) AS item_count FROM nutrition_items WHERE profile_id = %s AND date = ANY(%s) GROUP BY date """, (pid, dates), ) counts = {str(r["date"]): int(r["item_count"] or 0) for r in cur.fetchall()} for r in rows: r["item_count"] = counts.get(str(r.get("date")), 0) return rows @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}