From 47a268f42602cc5b52ce86e2ceff94d9e5273a35 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 21:52:57 +0100 Subject: [PATCH] fix: comprehensive PostgreSQL Decimal handling across all endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/main.py | 65 ++++++++++++++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/backend/main.py b/backend/main.py index 1417b6d..39e5ecc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,6 +2,7 @@ import os, csv, io, uuid, json, zipfile from pathlib import Path from typing import Optional from datetime import datetime +from decimal import Decimal from fastapi import FastAPI, HTTPException, UploadFile, File, Header, Query, Depends 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,)) rows = cur.fetchall() if not rows: return {"count":0,"latest":None,"prev":None,"min":None,"max":None,"avg_7d":None} - w=[r['weight'] for r in rows] - return {"count":len(rows),"latest":{"date":rows[0]['date'],"weight":rows[0]['weight']}, - "prev":{"date":rows[1]['date'],"weight":rows[1]['weight']} if len(rows)>1 else None, + w=[float(r['weight']) for r in rows] + return {"count":len(rows),"latest":{"date":rows[0]['date'],"weight":float(rows[0]['weight'])}, + "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)} # ── 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,)) rows = [r2d(r) for r in cur.fetchall()] 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_min=sum(r.get('duration_min') or 0 for r in rows) + total_kcal=sum(float(r.get('kcal_active') or 0) for r in rows) + total_min=sum(float(r.get('duration_min') or 0) for r in rows) by_type={} for r in rows: 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]['min']+=r.get('duration_min') or 0 + by_type[t]['count']+=1 + 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} @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: if d not in nutr and d not in wlog: continue 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 wlog: row['weight']=wlog[d] + 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']=float(wlog[d]) if d in cal_by_date: - row['lean_mass']=cal_by_date[d].get('lean_mass') - row['body_fat_pct']=cal_by_date[d].get('body_fat_pct') + lm = cal_by_date[d].get('lean_mass') + 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) return result @@ -613,7 +617,7 @@ def nutrition_weekly(weeks: int=16, x_profile_id: Optional[str]=Header(default=N result=[] for wk in sorted(wm): 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')}) return result @@ -768,10 +772,10 @@ def _prepare_template_vars(data: dict) -> dict: "name": prof.get('name', 'Nutzer'), "geschlecht": "männlich" if prof.get('sex') == 'm' else "weiblich", "height": prof.get('height', 178), - "goal_weight": prof.get('goal_weight') or "nicht gesetzt", - "goal_bf_pct": prof.get('goal_bf_pct') or "nicht gesetzt", - "weight_aktuell": 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", + "goal_weight": float(prof.get('goal_weight')) if prof.get('goal_weight') else "nicht gesetzt", + "goal_bf_pct": float(prof.get('goal_bf_pct')) if prof.get('goal_bf_pct') else "nicht gesetzt", + "weight_aktuell": float(weight[0]['weight']) if weight else "keine Daten", + "kf_aktuell": float(caliper[0]['body_fat_pct']) if caliper and caliper[0].get('body_fat_pct') else "unbekannt", } # Calculate age from dob @@ -798,7 +802,8 @@ def _prepare_template_vars(data: dict) -> dict: # Caliper summary if caliper: 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: vars['caliper_summary'] = "keine Daten" @@ -807,7 +812,7 @@ def _prepare_template_vars(data: dict) -> dict: c = circ[0] parts = [] 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" else: 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.execute("SELECT date, weight, note FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,)) 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 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(): - 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]) # Caliper 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(): - 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 cur.execute("SELECT date, kcal, protein_g FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,)) 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 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(): - 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) 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,)) 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( content=json_str, 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,)) 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 with get_db() as conn: