mitai-jinkendo/backend/routers/nutrition.py
Lars fa542e0fb9
All checks were successful
Deploy Development / deploy (push) Successful in 1m7s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
feat: Listen-Dialog mit Mengen/Einheiten, Mitai ohne Kochrezepte
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>
2026-09-12 18:23:01 +02:00

740 lines
28 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 fastapi.responses import Response
from pydantic import BaseModel
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__)
class RecipeIngredientIn(BaseModel):
source_name_raw: str
quantity_raw: Optional[str] = None
quantity_g: Optional[float] = None
quantity_amount: Optional[float] = None
source_unit: Optional[str] = None
grams_per_unit: Optional[float] = None
class RecipeIn(BaseModel):
name_raw: str
portions: float = 1
description: Optional[str] = None
ingredients: list[RecipeIngredientIn] = []
# ── 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(
since_days: int = 0,
count_only: bool = False,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.food_mapping import (
as_iso_date,
filter_unmapped_since,
merge_unmapped_rows,
normalize_food_name,
sort_unmapped_rows,
)
pid = x_profile_id or session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT source_name_normalized
FROM food_name_mappings
WHERE profile_id = %s
""",
(pid,),
)
mapped = {r["source_name_normalized"] for r in cur.fetchall()}
cur.execute(
"""
SELECT i.source_name_raw, i.source_name_normalized,
COUNT(*) AS count, MIN(i.date) AS first_date, MAX(i.date) AS last_date,
MIN(r.id::text) AS matching_recipe_id,
MIN(i.quantity_raw) AS sample_quantity_raw
FROM nutrition_items i
LEFT JOIN food_recipes r
ON r.profile_id = i.profile_id AND r.name_normalized = i.source_name_normalized
WHERE i.profile_id=%s AND i.food_id IS NULL AND i.recipe_id IS NULL
GROUP BY i.source_name_raw, i.source_name_normalized
ORDER BY count DESC, i.source_name_normalized
""",
(pid,),
)
diary = [r2d(r) | {"kind": "diary"} for r in cur.fetchall()]
cur.execute(
"""
SELECT i.source_name_raw, i.source_name_normalized,
COUNT(*) AS count, u.first_used AS first_date, u.last_used AS last_date,
MIN(i.quantity_raw) AS sample_quantity_raw
FROM food_recipe_ingredients i
JOIN food_recipes r ON r.id = i.recipe_id
LEFT JOIN food_name_mappings m
ON m.profile_id = r.profile_id
AND m.source_name_normalized = i.source_name_normalized
LEFT JOIN (
SELECT r2.id AS recipe_id, MIN(ni.date) AS first_used, MAX(ni.date) AS last_used
FROM food_recipes r2
JOIN nutrition_items ni
ON ni.profile_id = r2.profile_id
AND (ni.recipe_id = r2.id OR ni.source_name_normalized = r2.name_normalized)
WHERE r2.profile_id = %s
GROUP BY r2.id
) u ON u.recipe_id = r.id
WHERE r.profile_id = %s AND m.id IS NULL
GROUP BY i.source_name_raw, i.source_name_normalized, u.first_used, u.last_used
ORDER BY count DESC, i.source_name_normalized
""",
(pid, pid),
)
ings = [r2d(r) | {"kind": "recipe_ingredient"} for r in cur.fetchall()]
merged = merge_unmapped_rows(diary + ings)
out = []
for row in merged:
key = row.get("source_name_normalized") or normalize_food_name(row.get("source_name_raw"))
if key in mapped:
continue
row["last_date"] = as_iso_date(row.get("last_date"))
row["first_date"] = as_iso_date(row.get("first_date"))
out.append(row)
days = max(0, min(int(since_days or 0), 3650))
recent = sort_unmapped_rows(filter_unmapped_since(out, days), days)
if count_only:
return {"count": len(recent), "total": len(out), "since_days": days}
return recent if days else sort_unmapped_rows(out, 0)
@router.get("/recipes")
def list_food_recipes(
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.food_recipes import list_recipes
pid = get_pid(x_profile_id)
with get_db() as conn:
return list_recipes(get_cursor(conn), pid)
@router.post("/recipes")
def create_food_recipe(
body: RecipeIn,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.food_recipes import save_recipe
pid = get_pid(x_profile_id)
try:
with get_db() as conn:
return save_recipe(get_cursor(conn), pid, body.model_dump())
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.put("/recipes/{recipe_id}")
def update_food_recipe(
recipe_id: str,
body: RecipeIn,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.food_recipes import save_recipe
pid = get_pid(x_profile_id)
try:
with get_db() as conn:
return save_recipe(get_cursor(conn), pid, body.model_dump(), recipe_id)
except KeyError:
raise HTTPException(404, "Rezept nicht gefunden") from None
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.delete("/recipes/{recipe_id}")
def delete_food_recipe(
recipe_id: str,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.food_recipes import delete_recipe
pid = get_pid(x_profile_id)
try:
with get_db() as conn:
delete_recipe(get_cursor(conn), pid, recipe_id)
except KeyError:
raise HTTPException(404, "Rezept nicht gefunden") from None
return {"ok": True}
@router.post("/recipes/import-fddb-lists")
async def import_fddb_lists(
file: UploadFile = File(...),
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from bls.recipe_parser import parse_fddb_lists_csv
from data_layer.food_recipes import upsert_recipes
pid = get_pid(x_profile_id)
raw = await file.read()
if not raw:
raise HTTPException(400, "Leere Datei")
try:
text = raw.decode("utf-8-sig")
except UnicodeDecodeError:
text = raw.decode("latin-1")
recipes = parse_fddb_lists_csv(text)
if not recipes:
raise HTTPException(400, "Keine Rezepte in der Datei erkannt")
with get_db() as conn:
cur = get_cursor(conn)
stats = upsert_recipes(cur, pid, recipes)
from data_layer.nutrition_items import rebuild_daily_nutrients
for d in stats.pop("dates_linked", []) or []:
rebuild_daily_nutrients(cur, pid, d)
return {"ok": True, "recipes": len(recipes), **stats}
@router.post("/recipes/{recipe_id}/apply")
def apply_recipe_name(
recipe_id: str,
body: dict,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.food_mapping import normalize_food_name
from data_layer.food_recipes import apply_recipe_to_items
from data_layer.nutrition_items import dates_for_normalized_name, rebuild_daily_nutrients
pid = get_pid(x_profile_id)
source_name = (body.get("source_name") or "").strip()
if not source_name:
raise HTTPException(400, "source_name fehlt")
norm = normalize_food_name(source_name)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id FROM food_recipes WHERE id = %s AND profile_id = %s", (recipe_id, pid))
if not cur.fetchone():
raise HTTPException(404, "Rezept nicht gefunden")
n = apply_recipe_to_items(cur, pid, norm, recipe_id)
for d in dates_for_normalized_name(cur, pid, norm):
rebuild_daily_nutrients(cur, pid, d)
return {"ok": True, "items_updated": n}
@router.get("/food-knowledge")
def export_food_knowledge_file(
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
import json
from data_layer.food_knowledge import export_food_knowledge
pid = get_pid(x_profile_id)
try:
with get_db() as conn:
bundle = export_food_knowledge(get_cursor(conn), pid)
body = json.dumps(bundle, ensure_ascii=False, indent=2, default=str)
except Exception:
logger.exception("Export food-knowledge fehlgeschlagen")
raise HTTPException(500, "Export fehlgeschlagen") from None
stamp = datetime.now().strftime("%Y-%m-%d")
return Response(
content=body.encode("utf-8"),
media_type="application/json; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="mitai-food-knowledge-{stamp}.json"'},
)
@router.post("/food-knowledge")
async def import_food_knowledge_file(
file: UploadFile = File(...),
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
import json
from data_layer.food_knowledge import import_food_knowledge, parse_food_knowledge_bundle
pid = get_pid(x_profile_id)
raw = await file.read()
if not raw:
raise HTTPException(400, "Leere Datei")
try:
data = json.loads(raw.decode("utf-8-sig"))
parse_food_knowledge_bundle(data)
except ValueError as e:
raise HTTPException(400, str(e)) from e
except Exception as e:
raise HTTPException(400, f"Ungültiges JSON: {e}") from e
with get_db() as conn:
return import_food_knowledge(get_cursor(conn), pid, data)
@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}