fix: comprehensive PostgreSQL Decimal handling across all endpoints
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

Fixed all remaining Decimal → float conversion issues found by audit.

**Fixed Endpoints:**
1. Weight Stats: min/max/avg calculations
2. Activity Stats: kcal/duration accumulation
3. Nutrition Weekly: average calculations
4. Template Variables: all f-string Decimal formatting
5. CSV Export: all numeric value formatting
6. JSON Export: added Decimal handler
7. ZIP Export: added Decimal handler
8. Correlations: weight, nutrition, caliper values

**Changes:**
- Added `from decimal import Decimal` import
- Weight stats: convert to float for min/max/avg
- Activity: float() in sum() and accumulation
- Nutrition: float() in averages
- Template vars: float() for weight_aktuell, kf_aktuell, goals
- CSV: float() in all f-strings (weight, circ, caliper, nutrition, activity)
- JSON/ZIP: custom decimal_handler for json.dumps()
- Correlations: float() for all numeric DB values

Prevents:
- TypeError in math operations
- "Decimal('X')" strings in exports
- JSON serialization failures

All numeric values from PostgreSQL now properly converted to float.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-18 21:52:57 +01:00
parent f7f7f745b1
commit 47a268f426

View File

@ -2,6 +2,7 @@ import os, csv, io, uuid, json, zipfile
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
from decimal import Decimal
from fastapi import FastAPI, HTTPException, UploadFile, File, Header, Query, Depends from fastapi import FastAPI, HTTPException, UploadFile, File, Header, Query, Depends
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@ -268,9 +269,9 @@ def weight_stats(x_profile_id: Optional[str]=Header(default=None), session: dict
cur.execute("SELECT date,weight FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,)) cur.execute("SELECT date,weight FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,))
rows = cur.fetchall() rows = cur.fetchall()
if not rows: return {"count":0,"latest":None,"prev":None,"min":None,"max":None,"avg_7d":None} if not rows: return {"count":0,"latest":None,"prev":None,"min":None,"max":None,"avg_7d":None}
w=[r['weight'] for r in rows] w=[float(r['weight']) for r in rows]
return {"count":len(rows),"latest":{"date":rows[0]['date'],"weight":rows[0]['weight']}, return {"count":len(rows),"latest":{"date":rows[0]['date'],"weight":float(rows[0]['weight'])},
"prev":{"date":rows[1]['date'],"weight":rows[1]['weight']} if len(rows)>1 else None, "prev":{"date":rows[1]['date'],"weight":float(rows[1]['weight'])} if len(rows)>1 else None,
"min":min(w),"max":max(w),"avg_7d":round(sum(w[:7])/min(7,len(w)),2)} "min":min(w),"max":max(w),"avg_7d":round(sum(w[:7])/min(7,len(w)),2)}
# ── Circumferences ──────────────────────────────────────────────────────────── # ── Circumferences ────────────────────────────────────────────────────────────
@ -428,13 +429,14 @@ def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: di
"SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,)) "SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
rows = [r2d(r) for r in cur.fetchall()] rows = [r2d(r) for r in cur.fetchall()]
if not rows: return {"count":0,"total_kcal":0,"total_min":0,"by_type":{}} if not rows: return {"count":0,"total_kcal":0,"total_min":0,"by_type":{}}
total_kcal=sum(r.get('kcal_active') or 0 for r in rows) total_kcal=sum(float(r.get('kcal_active') or 0) for r in rows)
total_min=sum(r.get('duration_min') or 0 for r in rows) total_min=sum(float(r.get('duration_min') or 0) for r in rows)
by_type={} by_type={}
for r in rows: for r in rows:
t=r['activity_type']; by_type.setdefault(t,{'count':0,'kcal':0,'min':0}) t=r['activity_type']; by_type.setdefault(t,{'count':0,'kcal':0,'min':0})
by_type[t]['count']+=1; by_type[t]['kcal']+=r.get('kcal_active') or 0 by_type[t]['count']+=1
by_type[t]['min']+=r.get('duration_min') or 0 by_type[t]['kcal']+=float(r.get('kcal_active') or 0)
by_type[t]['min']+=float(r.get('duration_min') or 0)
return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type} return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type}
@app.post("/api/activity/import-csv") @app.post("/api/activity/import-csv")
@ -590,11 +592,13 @@ def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), ses
for d in all_dates: for d in all_dates:
if d not in nutr and d not in wlog: continue if d not in nutr and d not in wlog: continue
row={'date':d} row={'date':d}
if d in nutr: row.update({k:nutr[d][k] for k in ['kcal','protein_g','fat_g','carbs_g']}) if d in nutr: row.update({k:float(nutr[d][k]) if nutr[d][k] is not None else None for k in ['kcal','protein_g','fat_g','carbs_g']})
if d in wlog: row['weight']=wlog[d] if d in wlog: row['weight']=float(wlog[d])
if d in cal_by_date: if d in cal_by_date:
row['lean_mass']=cal_by_date[d].get('lean_mass') lm = cal_by_date[d].get('lean_mass')
row['body_fat_pct']=cal_by_date[d].get('body_fat_pct') bf = cal_by_date[d].get('body_fat_pct')
row['lean_mass']=float(lm) if lm is not None else None
row['body_fat_pct']=float(bf) if bf is not None else None
result.append(row) result.append(row)
return result return result
@ -613,7 +617,7 @@ def nutrition_weekly(weeks: int=16, x_profile_id: Optional[str]=Header(default=N
result=[] result=[]
for wk in sorted(wm): for wk in sorted(wm):
en=wm[wk]; n=len(en) en=wm[wk]; n=len(en)
def avg(k): return round(sum(e.get(k) or 0 for e in en)/n,1) 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')}) 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 return result
@ -768,10 +772,10 @@ def _prepare_template_vars(data: dict) -> dict:
"name": prof.get('name', 'Nutzer'), "name": prof.get('name', 'Nutzer'),
"geschlecht": "männlich" if prof.get('sex') == 'm' else "weiblich", "geschlecht": "männlich" if prof.get('sex') == 'm' else "weiblich",
"height": prof.get('height', 178), "height": prof.get('height', 178),
"goal_weight": prof.get('goal_weight') or "nicht gesetzt", "goal_weight": float(prof.get('goal_weight')) if prof.get('goal_weight') else "nicht gesetzt",
"goal_bf_pct": prof.get('goal_bf_pct') or "nicht gesetzt", "goal_bf_pct": float(prof.get('goal_bf_pct')) if prof.get('goal_bf_pct') else "nicht gesetzt",
"weight_aktuell": weight[0]['weight'] if weight else "keine Daten", "weight_aktuell": float(weight[0]['weight']) if weight else "keine Daten",
"kf_aktuell": caliper[0]['body_fat_pct'] if caliper and caliper[0].get('body_fat_pct') else "unbekannt", "kf_aktuell": float(caliper[0]['body_fat_pct']) if caliper and caliper[0].get('body_fat_pct') else "unbekannt",
} }
# Calculate age from dob # Calculate age from dob
@ -798,7 +802,8 @@ def _prepare_template_vars(data: dict) -> dict:
# Caliper summary # Caliper summary
if caliper: if caliper:
c = caliper[0] c = caliper[0]
vars['caliper_summary'] = f"KF: {c.get('body_fat_pct','?')}%, Methode: {c.get('sf_method','?')}" bf = float(c.get('body_fat_pct')) if c.get('body_fat_pct') else '?'
vars['caliper_summary'] = f"KF: {bf}%, Methode: {c.get('sf_method','?')}"
else: else:
vars['caliper_summary'] = "keine Daten" vars['caliper_summary'] = "keine Daten"
@ -807,7 +812,7 @@ def _prepare_template_vars(data: dict) -> dict:
c = circ[0] c = circ[0]
parts = [] parts = []
for k in ['c_waist', 'c_belly', 'c_hip']: for k in ['c_waist', 'c_belly', 'c_hip']:
if c.get(k): parts.append(f"{k.split('_')[1]}: {c[k]}cm") if c.get(k): parts.append(f"{k.split('_')[1]}: {float(c[k])}cm")
vars['circ_summary'] = ", ".join(parts) if parts else "keine Daten" vars['circ_summary'] = ", ".join(parts) if parts else "keine Daten"
else: else:
vars['circ_summary'] = "keine Daten" vars['circ_summary'] = "keine Daten"
@ -1431,28 +1436,28 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute("SELECT date, weight, note FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,)) cur.execute("SELECT date, weight, note FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,))
for r in cur.fetchall(): for r in cur.fetchall():
writer.writerow(["Gewicht", r['date'], f"{r['weight']}kg", r['note'] or ""]) writer.writerow(["Gewicht", r['date'], f"{float(r['weight'])}kg", r['note'] or ""])
# Circumferences # Circumferences
cur.execute("SELECT date, c_waist, c_belly, c_hip FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,)) cur.execute("SELECT date, c_waist, c_belly, c_hip FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,))
for r in cur.fetchall(): for r in cur.fetchall():
details = f"Taille:{r['c_waist']}cm Bauch:{r['c_belly']}cm Hüfte:{r['c_hip']}cm" details = f"Taille:{float(r['c_waist'])}cm Bauch:{float(r['c_belly'])}cm Hüfte:{float(r['c_hip'])}cm"
writer.writerow(["Umfänge", r['date'], "", details]) writer.writerow(["Umfänge", r['date'], "", details])
# Caliper # Caliper
cur.execute("SELECT date, body_fat_pct, lean_mass FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,)) cur.execute("SELECT date, body_fat_pct, lean_mass FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,))
for r in cur.fetchall(): for r in cur.fetchall():
writer.writerow(["Caliper", r['date'], f"{r['body_fat_pct']}%", f"Magermasse:{r['lean_mass']}kg"]) writer.writerow(["Caliper", r['date'], f"{float(r['body_fat_pct'])}%", f"Magermasse:{float(r['lean_mass'])}kg"])
# Nutrition # Nutrition
cur.execute("SELECT date, kcal, protein_g FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,)) cur.execute("SELECT date, kcal, protein_g FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,))
for r in cur.fetchall(): for r in cur.fetchall():
writer.writerow(["Ernährung", r['date'], f"{r['kcal']}kcal", f"Protein:{r['protein_g']}g"]) writer.writerow(["Ernährung", r['date'], f"{float(r['kcal'])}kcal", f"Protein:{float(r['protein_g'])}g"])
# Activity # Activity
cur.execute("SELECT date, activity_type, duration_min, kcal_active FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,)) cur.execute("SELECT date, activity_type, duration_min, kcal_active FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
for r in cur.fetchall(): for r in cur.fetchall():
writer.writerow(["Training", r['date'], r['activity_type'], f"{r['duration_min']}min {r['kcal_active']}kcal"]) writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"])
output.seek(0) output.seek(0)
return StreamingResponse( return StreamingResponse(
@ -1500,7 +1505,12 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=
cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,)) cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
data['insights'] = [r2d(r) for r in cur.fetchall()] data['insights'] = [r2d(r) for r in cur.fetchall()]
json_str = json.dumps(data, indent=2, default=str) def decimal_handler(obj):
if isinstance(obj, Decimal):
return float(obj)
return str(obj)
json_str = json.dumps(data, indent=2, default=decimal_handler)
return Response( return Response(
content=json_str, content=json_str,
media_type="application/json", media_type="application/json",
@ -1549,7 +1559,12 @@ def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=D
cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,)) cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
data['insights'] = [r2d(r) for r in cur.fetchall()] data['insights'] = [r2d(r) for r in cur.fetchall()]
zf.writestr("data.json", json.dumps(data, indent=2, default=str)) def decimal_handler(obj):
if isinstance(obj, Decimal):
return float(obj)
return str(obj)
zf.writestr("data.json", json.dumps(data, indent=2, default=decimal_handler))
# Add photos if they exist # Add photos if they exist
with get_db() as conn: with get_db() as conn: