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 from fastapi.responses import StreamingResponse, FileResponse, Response from pydantic import BaseModel import aiofiles import bcrypt from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded from starlette.requests import Request from db import get_db, get_cursor, r2d, init_db from auth import hash_pin, verify_pin, make_token, get_session, require_auth, require_auth_flexible, require_admin from models import ( ProfileCreate, ProfileUpdate, WeightEntry, CircumferenceEntry, CaliperEntry, ActivityEntry, NutritionDay, LoginRequest, PasswordResetRequest, PasswordResetConfirm, AdminProfileUpdate ) DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos")) DATA_DIR.mkdir(parents=True, exist_ok=True) PHOTOS_DIR.mkdir(parents=True, exist_ok=True) OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "") OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "anthropic/claude-sonnet-4") ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "") app = FastAPI(title="Mitai Jinkendo API", version="3.0.0") limiter = Limiter(key_func=get_remote_address) app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_middleware( CORSMiddleware, allow_origins=os.getenv("ALLOWED_ORIGINS", "*").split(","), allow_credentials=True, allow_methods=["GET","POST","PUT","DELETE","OPTIONS"], allow_headers=["*"], ) AVATAR_COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780'] @app.on_event("startup") async def startup_event(): """Run migrations and initialization on startup.""" try: init_db() except Exception as e: print(f"⚠️ init_db() failed (non-fatal): {e}") # Don't crash on startup - pipeline prompt can be created manually # ── Helper: get profile_id from header ─────────────────────────────────────── def get_pid(x_profile_id: Optional[str] = Header(default=None)) -> str: """Get profile_id - from header for legacy endpoints.""" if x_profile_id: return x_profile_id with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT id FROM profiles ORDER BY created LIMIT 1") row = cur.fetchone() if row: return row['id'] raise HTTPException(400, "Kein Profil gefunden") # ── Models ──────────────────────────────────────────────────────────────────── # ── Profiles ────────────────────────────────────────────────────────────────── from datetime import timedelta # Models moved to models.py # Auth functions moved to auth.py @app.get("/api/profiles") def list_profiles(session=Depends(require_auth)): with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles ORDER BY created") rows = cur.fetchall() return [r2d(r) for r in rows] @app.post("/api/profiles") def create_profile(p: ProfileCreate, session=Depends(require_auth)): pid = str(uuid.uuid4()) with get_db() as conn: cur = get_cursor(conn) cur.execute("""INSERT INTO profiles (id,name,avatar_color,sex,dob,height,goal_weight,goal_bf_pct,created,updated) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)""", (pid,p.name,p.avatar_color,p.sex,p.dob,p.height,p.goal_weight,p.goal_bf_pct)) with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) return r2d(cur.fetchone()) @app.get("/api/profiles/{pid}") def get_profile(pid: str, session=Depends(require_auth)): with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) row = cur.fetchone() if not row: raise HTTPException(404, "Profil nicht gefunden") return r2d(row) @app.put("/api/profiles/{pid}") def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)): with get_db() as conn: data = {k:v for k,v in p.model_dump().items() if v is not None} data['updated'] = datetime.now().isoformat() cur = get_cursor(conn) cur.execute(f"UPDATE profiles SET {', '.join(f'{k}=%s' for k in data)} WHERE id=%s", list(data.values())+[pid]) return get_profile(pid, session) @app.delete("/api/profiles/{pid}") def delete_profile(pid: str, session=Depends(require_auth)): with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT COUNT(*) as count FROM profiles") count = cur.fetchone()['count'] if count <= 1: raise HTTPException(400, "Letztes Profil kann nicht gelöscht werden") for table in ['weight_log','circumference_log','caliper_log','nutrition_log','activity_log','ai_insights']: cur.execute(f"DELETE FROM {table} WHERE profile_id=%s", (pid,)) cur.execute("DELETE FROM profiles WHERE id=%s", (pid,)) return {"ok": True} @app.get("/api/profile") def get_active_profile(x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)): """Legacy endpoint – returns active profile.""" pid = get_pid(x_profile_id) return get_profile(pid, session) @app.put("/api/profile") def update_active_profile(p: ProfileUpdate, x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)): pid = get_pid(x_profile_id) return update_profile(pid, p, session) # ── Weight ──────────────────────────────────────────────────────────────────── @app.get("/api/weight") def list_weight(limit: int=365, 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 weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit)) return [r2d(r) for r in cur.fetchall()] @app.post("/api/weight") def upsert_weight(e: WeightEntry, 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 id FROM weight_log WHERE profile_id=%s AND date=%s", (pid,e.date)) ex = cur.fetchone() if ex: cur.execute("UPDATE weight_log SET weight=%s,note=%s WHERE id=%s", (e.weight,e.note,ex['id'])) wid = ex['id'] else: wid = str(uuid.uuid4()) cur.execute("INSERT INTO weight_log (id,profile_id,date,weight,note,created) VALUES (%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)", (wid,pid,e.date,e.weight,e.note)) return {"id":wid,"date":e.date,"weight":e.weight} @app.put("/api/weight/{wid}") def update_weight(wid: str, e: WeightEntry, 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("UPDATE weight_log SET date=%s,weight=%s,note=%s WHERE id=%s AND profile_id=%s", (e.date,e.weight,e.note,wid,pid)) return {"id":wid} @app.delete("/api/weight/{wid}") def delete_weight(wid: 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 weight_log WHERE id=%s AND profile_id=%s", (wid,pid)) return {"ok":True} @app.get("/api/weight/stats") def weight_stats(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 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=[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 ──────────────────────────────────────────────────────────── @app.get("/api/circumferences") def list_circs(limit: int=100, 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 circumference_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit)) return [r2d(r) for r in cur.fetchall()] @app.post("/api/circumferences") def upsert_circ(e: CircumferenceEntry, 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 id FROM circumference_log WHERE profile_id=%s AND date=%s", (pid,e.date)) ex = cur.fetchone() d = e.model_dump() if ex: eid = ex['id'] sets = ', '.join(f"{k}=%s" for k in d if k!='date') cur.execute(f"UPDATE circumference_log SET {sets} WHERE id=%s", [v for k,v in d.items() if k!='date']+[eid]) else: eid = str(uuid.uuid4()) cur.execute("""INSERT INTO circumference_log (id,profile_id,date,c_neck,c_chest,c_waist,c_belly,c_hip,c_thigh,c_calf,c_arm,notes,photo_id,created) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""", (eid,pid,d['date'],d['c_neck'],d['c_chest'],d['c_waist'],d['c_belly'], d['c_hip'],d['c_thigh'],d['c_calf'],d['c_arm'],d['notes'],d['photo_id'])) return {"id":eid,"date":e.date} @app.put("/api/circumferences/{eid}") def update_circ(eid: str, e: CircumferenceEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: d = e.model_dump() cur = get_cursor(conn) cur.execute(f"UPDATE circumference_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s", list(d.values())+[eid,pid]) return {"id":eid} @app.delete("/api/circumferences/{eid}") def delete_circ(eid: 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 circumference_log WHERE id=%s AND profile_id=%s", (eid,pid)) return {"ok":True} # ── Caliper ─────────────────────────────────────────────────────────────────── @app.get("/api/caliper") def list_caliper(limit: int=100, 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 caliper_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit)) return [r2d(r) for r in cur.fetchall()] @app.post("/api/caliper") def upsert_caliper(e: CaliperEntry, 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 id FROM caliper_log WHERE profile_id=%s AND date=%s", (pid,e.date)) ex = cur.fetchone() d = e.model_dump() if ex: eid = ex['id'] sets = ', '.join(f"{k}=%s" for k in d if k!='date') cur.execute(f"UPDATE caliper_log SET {sets} WHERE id=%s", [v for k,v in d.items() if k!='date']+[eid]) else: eid = str(uuid.uuid4()) cur.execute("""INSERT INTO caliper_log (id,profile_id,date,sf_method,sf_chest,sf_axilla,sf_triceps,sf_subscap,sf_suprailiac, sf_abdomen,sf_thigh,sf_calf_med,sf_lowerback,sf_biceps,body_fat_pct,lean_mass,fat_mass,notes,created) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""", (eid,pid,d['date'],d['sf_method'],d['sf_chest'],d['sf_axilla'],d['sf_triceps'], d['sf_subscap'],d['sf_suprailiac'],d['sf_abdomen'],d['sf_thigh'],d['sf_calf_med'], d['sf_lowerback'],d['sf_biceps'],d['body_fat_pct'],d['lean_mass'],d['fat_mass'],d['notes'])) return {"id":eid,"date":e.date} @app.put("/api/caliper/{eid}") def update_caliper(eid: str, e: CaliperEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: d = e.model_dump() cur = get_cursor(conn) cur.execute(f"UPDATE caliper_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s", list(d.values())+[eid,pid]) return {"id":eid} @app.delete("/api/caliper/{eid}") def delete_caliper(eid: 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 caliper_log WHERE id=%s AND profile_id=%s", (eid,pid)) return {"ok":True} # ── Activity ────────────────────────────────────────────────────────────────── @app.get("/api/activity") def list_activity(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) cur.execute( "SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC, start_time DESC LIMIT %s", (pid,limit)) return [r2d(r) for r in cur.fetchall()] @app.post("/api/activity") def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) eid = str(uuid.uuid4()) d = e.model_dump() with get_db() as conn: cur = get_cursor(conn) cur.execute("""INSERT INTO activity_log (id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting, hr_avg,hr_max,distance_km,rpe,source,notes,created) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""", (eid,pid,d['date'],d['start_time'],d['end_time'],d['activity_type'],d['duration_min'], d['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'], d['rpe'],d['source'],d['notes'])) return {"id":eid,"date":e.date} @app.put("/api/activity/{eid}") def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: d = e.model_dump() cur = get_cursor(conn) cur.execute(f"UPDATE activity_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s", list(d.values())+[eid,pid]) return {"id":eid} @app.delete("/api/activity/{eid}") def delete_activity(eid: 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 activity_log WHERE id=%s AND profile_id=%s", (eid,pid)) return {"ok":True} @app.get("/api/activity/stats") def activity_stats(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 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(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']+=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") async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) 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") reader = csv.DictReader(io.StringIO(text)) inserted = skipped = 0 with get_db() as conn: cur = get_cursor(conn) for row in reader: wtype = row.get('Workout Type','').strip() start = row.get('Start','').strip() if not wtype or not start: continue try: date = start[:10] except: continue dur = row.get('Duration','').strip() duration_min = None if dur: try: p = dur.split(':') duration_min = round(int(p[0])*60+int(p[1])+int(p[2])/60,1) except: pass def kj(v): try: return round(float(v)/4.184) if v else None except: return None def tf(v): try: return round(float(v),1) if v else None except: return None try: cur.execute("""INSERT INTO activity_log (id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting, hr_avg,hr_max,distance_km,source,created) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',CURRENT_TIMESTAMP)""", (str(uuid.uuid4()),pid,date,start,row.get('End',''),wtype,duration_min, kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')), tf(row.get('Durchschn. Herzfrequenz (count/min)','')), tf(row.get('Max. Herzfrequenz (count/min)','')), tf(row.get('Distanz (km)','')))) inserted+=1 except: skipped+=1 return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"} # ── Photos ──────────────────────────────────────────────────────────────────── @app.post("/api/photos") async def upload_photo(file: UploadFile=File(...), date: str="", x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) fid = str(uuid.uuid4()) ext = Path(file.filename).suffix or '.jpg' path = PHOTOS_DIR / f"{fid}{ext}" async with aiofiles.open(path,'wb') as f: await f.write(await file.read()) with get_db() as conn: cur = get_cursor(conn) cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)", (fid,pid,date,str(path))) return {"id":fid,"date":date} @app.get("/api/photos/{fid}") def get_photo(fid: str, session: dict=Depends(require_auth_flexible)): """Get photo by ID. Auth via header or query param (for tags).""" with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT path FROM photos WHERE id=%s", (fid,)) row = cur.fetchone() if not row: raise HTTPException(404, "Photo not found") photo_path = Path(PHOTOS_DIR) / row['path'] if not photo_path.exists(): raise HTTPException(404, "Photo file not found") return FileResponse(photo_path) @app.get("/api/photos") def list_photos(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 photos WHERE profile_id=%s ORDER BY created DESC LIMIT 100", (pid,)) return [r2d(r) for r in cur.fetchall()] # ── Nutrition ───────────────────────────────────────────────────────────────── def _pf(s): try: return float(str(s).replace(',','.').strip()) except: return 0.0 @app.post("/api/nutrition/import-csv") async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) 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") reader = csv.DictReader(io.StringIO(text), delimiter=';') days: dict = {} count = 0 for row in reader: rd = row.get('datum_tag_monat_jahr_stunde_minute','').strip().strip('"') if not rd: continue try: p = rd.split(' ')[0].split('.') iso = f"{p[2]}-{p[1]}-{p[0]}" except: continue days.setdefault(iso,{'kcal':0,'fat_g':0,'carbs_g':0,'protein_g':0}) days[iso]['kcal'] += _pf(row.get('kj',0))/4.184 days[iso]['fat_g'] += _pf(row.get('fett_g',0)) days[iso]['carbs_g'] += _pf(row.get('kh_g',0)) days[iso]['protein_g'] += _pf(row.get('protein_g',0)) count+=1 inserted=0 with get_db() as conn: cur = get_cursor(conn) 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)) if cur.fetchone(): cur.execute("UPDATE nutrition_log SET kcal=%s,protein_g=%s,fat_g=%s,carbs_g=%s 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,created) VALUES (%s,%s,%s,%s,%s,%s,%s,'csv',CURRENT_TIMESTAMP)", (str(uuid.uuid4()),pid,iso,kcal,prot,fat,carbs)) inserted+=1 return {"rows_parsed":count,"days_imported":inserted, "date_range":{"from":min(days) if days else None,"to":max(days) if days else None}} @app.get("/api/nutrition") def list_nutrition(limit: int=365, 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_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit)) return [r2d(r) for r in cur.fetchall()] @app.get("/api/nutrition/correlations") def nutrition_correlations(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_log WHERE profile_id=%s ORDER BY date",(pid,)) nutr={r['date']:r2d(r) for r in cur.fetchall()} cur.execute("SELECT date,weight FROM weight_log WHERE profile_id=%s ORDER BY date",(pid,)) wlog={r['date']:r['weight'] for r in cur.fetchall()} cur.execute("SELECT date,lean_mass,body_fat_pct FROM caliper_log WHERE profile_id=%s ORDER BY date",(pid,)) cals=sorted([r2d(r) for r in cur.fetchall()],key=lambda x:x['date']) all_dates=sorted(set(list(nutr)+list(wlog))) mi,last_cal,cal_by_date=0,{},{} for d in all_dates: while mi= limit: raise HTTPException(429, f"Tägliches KI-Limit erreicht ({limit} Calls)") return (True, limit, used) def inc_ai_usage(pid: str): """Increment AI usage counter for today.""" today = datetime.now().date().isoformat() with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT id, call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today)) row = cur.fetchone() if row: cur.execute("UPDATE ai_usage SET call_count=%s WHERE id=%s", (row['call_count']+1, row['id'])) else: cur.execute("INSERT INTO ai_usage (id, profile_id, date, call_count) VALUES (%s,%s,%s,1)", (str(uuid.uuid4()), pid, today)) def _get_profile_data(pid: str): """Fetch all relevant data for AI analysis.""" with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) prof = r2d(cur.fetchone()) cur.execute("SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,)) weight = [r2d(r) for r in cur.fetchall()] cur.execute("SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,)) circ = [r2d(r) for r in cur.fetchall()] cur.execute("SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,)) caliper = [r2d(r) for r in cur.fetchall()] cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,)) nutrition = [r2d(r) for r in cur.fetchall()] cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,)) activity = [r2d(r) for r in cur.fetchall()] return { "profile": prof, "weight": weight, "circumference": circ, "caliper": caliper, "nutrition": nutrition, "activity": activity } def _render_template(template: str, data: dict) -> str: """Simple template variable replacement.""" result = template for k, v in data.items(): result = result.replace(f"{{{{{k}}}}}", str(v) if v is not None else "") return result def _prepare_template_vars(data: dict) -> dict: """Prepare template variables from profile data.""" prof = data['profile'] weight = data['weight'] circ = data['circumference'] caliper = data['caliper'] nutrition = data['nutrition'] activity = data['activity'] vars = { "name": prof.get('name', 'Nutzer'), "geschlecht": "männlich" if prof.get('sex') == 'm' else "weiblich", "height": prof.get('height', 178), "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 if prof.get('dob'): try: from datetime import date dob = datetime.strptime(prof['dob'], '%Y-%m-%d').date() today = date.today() age = today.year - dob.year - ((today.month, today.day) < (dob.month, dob.day)) vars['age'] = age except: vars['age'] = "unbekannt" else: vars['age'] = "unbekannt" # Weight trend summary if len(weight) >= 2: recent = weight[:30] delta = float(recent[0]['weight']) - float(recent[-1]['weight']) vars['weight_trend'] = f"{len(recent)} Einträge, Δ30d: {delta:+.1f}kg" else: vars['weight_trend'] = "zu wenig Daten" # Caliper summary if caliper: c = caliper[0] 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" # Circumference summary if circ: c = circ[0] parts = [] for k in ['c_waist', 'c_belly', 'c_hip']: 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" # Nutrition summary if nutrition: n = len(nutrition) avg_kcal = sum(float(d.get('kcal',0) or 0) for d in nutrition) / n avg_prot = sum(float(d.get('protein_g',0) or 0) for d in nutrition) / n vars['nutrition_summary'] = f"{n} Tage, Ø {avg_kcal:.0f}kcal, {avg_prot:.0f}g Protein" vars['nutrition_detail'] = vars['nutrition_summary'] vars['nutrition_days'] = n vars['kcal_avg'] = round(avg_kcal) vars['protein_avg'] = round(avg_prot,1) vars['fat_avg'] = round(sum(float(d.get('fat_g',0) or 0) for d in nutrition) / n,1) vars['carb_avg'] = round(sum(float(d.get('carbs_g',0) or 0) for d in nutrition) / n,1) else: vars['nutrition_summary'] = "keine Daten" vars['nutrition_detail'] = "keine Daten" vars['nutrition_days'] = 0 vars['kcal_avg'] = 0 vars['protein_avg'] = 0 vars['fat_avg'] = 0 vars['carb_avg'] = 0 # Protein targets w = weight[0]['weight'] if weight else prof.get('height',178) - 100 w = float(w) # Convert Decimal to float for math operations vars['protein_ziel_low'] = round(w * 1.6) vars['protein_ziel_high'] = round(w * 2.2) # Activity summary if activity: n = len(activity) total_kcal = sum(float(a.get('kcal_active',0) or 0) for a in activity) vars['activity_summary'] = f"{n} Trainings, {total_kcal:.0f}kcal gesamt" vars['activity_detail'] = vars['activity_summary'] vars['activity_kcal_summary'] = f"Ø {total_kcal/n:.0f}kcal/Training" else: vars['activity_summary'] = "keine Daten" vars['activity_detail'] = "keine Daten" vars['activity_kcal_summary'] = "keine Daten" return vars @app.post("/api/insights/run/{slug}") async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Run AI analysis with specified prompt template.""" pid = get_pid(x_profile_id) check_ai_limit(pid) # Get prompt template with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM ai_prompts WHERE slug=%s AND active=true", (slug,)) prompt_row = cur.fetchone() if not prompt_row: raise HTTPException(404, f"Prompt '{slug}' nicht gefunden") prompt_tmpl = prompt_row['template'] data = _get_profile_data(pid) vars = _prepare_template_vars(data) final_prompt = _render_template(prompt_tmpl, vars) # Call AI if ANTHROPIC_KEY: # Use Anthropic SDK import anthropic client = anthropic.Anthropic(api_key=ANTHROPIC_KEY) response = client.messages.create( model="claude-sonnet-4-20250514", max_tokens=2000, messages=[{"role": "user", "content": final_prompt}] ) content = response.content[0].text elif OPENROUTER_KEY: async with httpx.AsyncClient() as client: resp = await client.post("https://openrouter.ai/api/v1/chat/completions", headers={"Authorization": f"Bearer {OPENROUTER_KEY}"}, json={ "model": OPENROUTER_MODEL, "messages": [{"role": "user", "content": final_prompt}], "max_tokens": 2000 }, timeout=60.0 ) if resp.status_code != 200: raise HTTPException(500, f"KI-Fehler: {resp.text}") content = resp.json()['choices'][0]['message']['content'] else: raise HTTPException(500, "Keine KI-API konfiguriert") # Save insight with get_db() as conn: cur = get_cursor(conn) cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope=%s", (pid, slug)) cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)", (str(uuid.uuid4()), pid, slug, content)) inc_ai_usage(pid) return {"scope": slug, "content": content} @app.post("/api/insights/pipeline") async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Run 3-stage pipeline analysis.""" pid = get_pid(x_profile_id) check_ai_limit(pid) data = _get_profile_data(pid) vars = _prepare_template_vars(data) # Stage 1: Parallel JSON analyses with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT slug, template FROM ai_prompts WHERE slug LIKE 'pipeline_%' AND slug NOT IN ('pipeline_synthesis','pipeline_goals') AND active=true") stage1_prompts = [r2d(r) for r in cur.fetchall()] stage1_results = {} for p in stage1_prompts: slug = p['slug'] final_prompt = _render_template(p['template'], vars) if ANTHROPIC_KEY: import anthropic client = anthropic.Anthropic(api_key=ANTHROPIC_KEY) response = client.messages.create( model="claude-sonnet-4-20250514", max_tokens=1000, messages=[{"role": "user", "content": final_prompt}] ) content = response.content[0].text.strip() elif OPENROUTER_KEY: async with httpx.AsyncClient() as client: resp = await client.post("https://openrouter.ai/api/v1/chat/completions", headers={"Authorization": f"Bearer {OPENROUTER_KEY}"}, json={ "model": OPENROUTER_MODEL, "messages": [{"role": "user", "content": final_prompt}], "max_tokens": 1000 }, timeout=60.0 ) content = resp.json()['choices'][0]['message']['content'].strip() else: raise HTTPException(500, "Keine KI-API konfiguriert") # Try to parse JSON, fallback to raw text try: stage1_results[slug] = json.loads(content) except: stage1_results[slug] = content # Stage 2: Synthesis vars['stage1_body'] = json.dumps(stage1_results.get('pipeline_body', {}), ensure_ascii=False) vars['stage1_nutrition'] = json.dumps(stage1_results.get('pipeline_nutrition', {}), ensure_ascii=False) vars['stage1_activity'] = json.dumps(stage1_results.get('pipeline_activity', {}), ensure_ascii=False) with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT template FROM ai_prompts WHERE slug='pipeline_synthesis' AND active=true") synth_row = cur.fetchone() if not synth_row: raise HTTPException(500, "Pipeline synthesis prompt not found") synth_prompt = _render_template(synth_row['template'], vars) if ANTHROPIC_KEY: import anthropic client = anthropic.Anthropic(api_key=ANTHROPIC_KEY) response = client.messages.create( model="claude-sonnet-4-20250514", max_tokens=2000, messages=[{"role": "user", "content": synth_prompt}] ) synthesis = response.content[0].text elif OPENROUTER_KEY: async with httpx.AsyncClient() as client: resp = await client.post("https://openrouter.ai/api/v1/chat/completions", headers={"Authorization": f"Bearer {OPENROUTER_KEY}"}, json={ "model": OPENROUTER_MODEL, "messages": [{"role": "user", "content": synth_prompt}], "max_tokens": 2000 }, timeout=60.0 ) synthesis = resp.json()['choices'][0]['message']['content'] else: raise HTTPException(500, "Keine KI-API konfiguriert") # Stage 3: Goals (only if goals are set) goals_text = None prof = data['profile'] if prof.get('goal_weight') or prof.get('goal_bf_pct'): with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT template FROM ai_prompts WHERE slug='pipeline_goals' AND active=true") goals_row = cur.fetchone() if goals_row: goals_prompt = _render_template(goals_row['template'], vars) if ANTHROPIC_KEY: import anthropic client = anthropic.Anthropic(api_key=ANTHROPIC_KEY) response = client.messages.create( model="claude-sonnet-4-20250514", max_tokens=800, messages=[{"role": "user", "content": goals_prompt}] ) goals_text = response.content[0].text elif OPENROUTER_KEY: async with httpx.AsyncClient() as client: resp = await client.post("https://openrouter.ai/api/v1/chat/completions", headers={"Authorization": f"Bearer {OPENROUTER_KEY}"}, json={ "model": OPENROUTER_MODEL, "messages": [{"role": "user", "content": goals_prompt}], "max_tokens": 800 }, timeout=60.0 ) goals_text = resp.json()['choices'][0]['message']['content'] # Combine synthesis + goals final_content = synthesis if goals_text: final_content += "\n\n" + goals_text # Save as 'gesamt' scope with get_db() as conn: cur = get_cursor(conn) cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope='gesamt'", (pid,)) cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'gesamt',%s,CURRENT_TIMESTAMP)", (str(uuid.uuid4()), pid, final_content)) inc_ai_usage(pid) return {"scope": "gesamt", "content": final_content, "stage1": stage1_results} @app.get("/api/prompts") def list_prompts(session: dict=Depends(require_auth)): """ List AI prompts. - Admins: see ALL prompts (including pipeline and inactive) - Users: see only active single-analysis prompts """ with get_db() as conn: cur = get_cursor(conn) is_admin = session.get('role') == 'admin' if is_admin: # Admin sees everything cur.execute("SELECT * FROM ai_prompts ORDER BY sort_order, slug") else: # Users see only active, non-pipeline prompts cur.execute("SELECT * FROM ai_prompts WHERE active=true AND slug NOT LIKE 'pipeline_%' ORDER BY sort_order") return [r2d(r) for r in cur.fetchall()] @app.put("/api/prompts/{prompt_id}") def update_prompt(prompt_id: str, data: dict, session: dict=Depends(require_admin)): """Update AI prompt template (admin only).""" with get_db() as conn: cur = get_cursor(conn) updates = [] values = [] if 'name' in data: updates.append('name=%s') values.append(data['name']) if 'description' in data: updates.append('description=%s') values.append(data['description']) if 'template' in data: updates.append('template=%s') values.append(data['template']) if 'active' in data: updates.append('active=%s') # Convert to boolean (accepts true/false, 1/0) values.append(bool(data['active'])) if updates: cur.execute(f"UPDATE ai_prompts SET {', '.join(updates)}, updated=CURRENT_TIMESTAMP WHERE id=%s", values + [prompt_id]) return {"ok": True} @app.get("/api/ai/usage") def get_ai_usage(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Get AI usage stats for current profile.""" pid = get_pid(x_profile_id) with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT ai_limit_day FROM profiles WHERE id=%s", (pid,)) prof = cur.fetchone() limit = prof['ai_limit_day'] if prof else None today = datetime.now().date().isoformat() cur.execute("SELECT call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today)) usage = cur.fetchone() used = usage['call_count'] if usage else 0 cur.execute("SELECT date, call_count FROM ai_usage WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,)) history = [r2d(r) for r in cur.fetchall()] return { "limit": limit, "used_today": used, "remaining": (limit - used) if limit else None, "history": history } # ── Auth ────────────────────────────────────────────────────────────────────── @app.post("/api/auth/login") @limiter.limit("5/minute") async def login(req: LoginRequest, request: Request): """Login with email + password.""" with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles WHERE email=%s", (req.email.lower().strip(),)) prof = cur.fetchone() if not prof: raise HTTPException(401, "Ungültige Zugangsdaten") # Verify password if not verify_pin(req.password, prof['pin_hash']): raise HTTPException(401, "Ungültige Zugangsdaten") # Auto-upgrade from SHA256 to bcrypt if prof['pin_hash'] and not prof['pin_hash'].startswith('$2'): new_hash = hash_pin(req.password) cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, prof['id'])) # Create session token = make_token() session_days = prof.get('session_days', 30) expires = datetime.now() + timedelta(days=session_days) cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)", (token, prof['id'], expires.isoformat())) return { "token": token, "profile_id": prof['id'], "name": prof['name'], "role": prof['role'], "expires_at": expires.isoformat() } @app.post("/api/auth/logout") def logout(x_auth_token: Optional[str]=Header(default=None)): """Logout (delete session).""" if x_auth_token: with get_db() as conn: cur = get_cursor(conn) cur.execute("DELETE FROM sessions WHERE token=%s", (x_auth_token,)) return {"ok": True} @app.get("/api/auth/me") def get_me(session: dict=Depends(require_auth)): """Get current user info.""" pid = session['profile_id'] return get_profile(pid, session) @app.get("/api/auth/status") def auth_status(): """Health check endpoint.""" return {"status": "ok", "service": "mitai-jinkendo", "version": "v9b"} @app.put("/api/auth/pin") def change_pin(req: dict, session: dict=Depends(require_auth)): """Change PIN/password for current user.""" pid = session['profile_id'] new_pin = req.get('pin', '') if len(new_pin) < 4: raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben") new_hash = hash_pin(new_pin) with get_db() as conn: cur = get_cursor(conn) cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid)) return {"ok": True} @app.post("/api/auth/forgot-password") @limiter.limit("3/minute") async def password_reset_request(req: PasswordResetRequest, request: Request): """Request password reset email.""" email = req.email.lower().strip() with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT id, name FROM profiles WHERE email=%s", (email,)) prof = cur.fetchone() if not prof: # Don't reveal if email exists return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."} # Generate reset token token = secrets.token_urlsafe(32) expires = datetime.now() + timedelta(hours=1) # Store in sessions table (reuse mechanism) cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)", (f"reset_{token}", prof['id'], expires.isoformat())) # Send email try: import smtplib from email.mime.text import MIMEText smtp_host = os.getenv("SMTP_HOST") smtp_port = int(os.getenv("SMTP_PORT", 587)) smtp_user = os.getenv("SMTP_USER") smtp_pass = os.getenv("SMTP_PASS") smtp_from = os.getenv("SMTP_FROM") app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de") if smtp_host and smtp_user and smtp_pass: msg = MIMEText(f"""Hallo {prof['name']}, Du hast einen Passwort-Reset angefordert. Reset-Link: {app_url}/reset-password?token={token} Der Link ist 1 Stunde gültig. Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail. Dein Mitai Jinkendo Team """) msg['Subject'] = "Passwort zurücksetzen – Mitai Jinkendo" msg['From'] = smtp_from msg['To'] = email with smtplib.SMTP(smtp_host, smtp_port) as server: server.starttls() server.login(smtp_user, smtp_pass) server.send_message(msg) except Exception as e: print(f"Email error: {e}") return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."} @app.post("/api/auth/reset-password") def password_reset_confirm(req: PasswordResetConfirm): """Confirm password reset with token.""" with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT profile_id FROM sessions WHERE token=%s AND expires_at > CURRENT_TIMESTAMP", (f"reset_{req.token}",)) sess = cur.fetchone() if not sess: raise HTTPException(400, "Ungültiger oder abgelaufener Reset-Link") pid = sess['profile_id'] new_hash = hash_pin(req.new_password) cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid)) cur.execute("DELETE FROM sessions WHERE token=%s", (f"reset_{req.token}",)) return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"} # ── Admin ───────────────────────────────────────────────────────────────────── @app.get("/api/admin/profiles") def admin_list_profiles(session: dict=Depends(require_admin)): """Admin: List all profiles with stats.""" with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles ORDER BY created") profs = [r2d(r) for r in cur.fetchall()] for p in profs: pid = p['id'] cur.execute("SELECT COUNT(*) as count FROM weight_log WHERE profile_id=%s", (pid,)) p['weight_count'] = cur.fetchone()['count'] cur.execute("SELECT COUNT(*) as count FROM ai_insights WHERE profile_id=%s", (pid,)) p['ai_insights_count'] = cur.fetchone()['count'] today = datetime.now().date().isoformat() cur.execute("SELECT call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today)) usage = cur.fetchone() p['ai_usage_today'] = usage['call_count'] if usage else 0 return profs @app.put("/api/admin/profiles/{pid}") def admin_update_profile(pid: str, data: AdminProfileUpdate, session: dict=Depends(require_admin)): """Admin: Update profile settings.""" with get_db() as conn: updates = {k:v for k,v in data.model_dump().items() if v is not None} if not updates: return {"ok": True} cur = get_cursor(conn) cur.execute(f"UPDATE profiles SET {', '.join(f'{k}=%s' for k in updates)} WHERE id=%s", list(updates.values()) + [pid]) return {"ok": True} @app.put("/api/admin/profiles/{pid}/permissions") def admin_set_permissions(pid: str, data: dict, session: dict=Depends(require_admin)): """Admin: Set profile permissions.""" with get_db() as conn: cur = get_cursor(conn) updates = [] values = [] if 'ai_enabled' in data: updates.append('ai_enabled=%s') values.append(data['ai_enabled']) if 'ai_limit_day' in data: updates.append('ai_limit_day=%s') values.append(data['ai_limit_day']) if 'export_enabled' in data: updates.append('export_enabled=%s') values.append(data['export_enabled']) if 'role' in data: updates.append('role=%s') values.append(data['role']) if updates: cur.execute(f"UPDATE profiles SET {', '.join(updates)} WHERE id=%s", values + [pid]) return {"ok": True} @app.put("/api/admin/profiles/{pid}/email") def admin_set_email(pid: str, data: dict, session: dict=Depends(require_admin)): """Admin: Set profile email.""" email = data.get('email', '').strip().lower() with get_db() as conn: cur = get_cursor(conn) cur.execute("UPDATE profiles SET email=%s WHERE id=%s", (email if email else None, pid)) return {"ok": True} @app.put("/api/admin/profiles/{pid}/pin") def admin_set_pin(pid: str, data: dict, session: dict=Depends(require_admin)): """Admin: Set profile PIN/password.""" new_pin = data.get('pin', '') if len(new_pin) < 4: raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben") new_hash = hash_pin(new_pin) with get_db() as conn: cur = get_cursor(conn) cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid)) return {"ok": True} @app.get("/api/admin/email/status") def admin_email_status(session: dict=Depends(require_admin)): """Admin: Check email configuration status.""" smtp_host = os.getenv("SMTP_HOST") smtp_user = os.getenv("SMTP_USER") smtp_pass = os.getenv("SMTP_PASS") app_url = os.getenv("APP_URL", "http://localhost:3002") configured = bool(smtp_host and smtp_user and smtp_pass) return { "configured": configured, "smtp_host": smtp_host or "", "smtp_user": smtp_user or "", "app_url": app_url } @app.post("/api/admin/email/test") def admin_test_email(data: dict, session: dict=Depends(require_admin)): """Admin: Send test email.""" email = data.get('to', '') if not email: raise HTTPException(400, "E-Mail-Adresse fehlt") try: import smtplib from email.mime.text import MIMEText smtp_host = os.getenv("SMTP_HOST") smtp_port = int(os.getenv("SMTP_PORT", 587)) smtp_user = os.getenv("SMTP_USER") smtp_pass = os.getenv("SMTP_PASS") smtp_from = os.getenv("SMTP_FROM") if not smtp_host or not smtp_user or not smtp_pass: raise HTTPException(500, "SMTP nicht konfiguriert") msg = MIMEText("Dies ist eine Test-E-Mail von Mitai Jinkendo.") msg['Subject'] = "Test-E-Mail" msg['From'] = smtp_from msg['To'] = email with smtplib.SMTP(smtp_host, smtp_port) as server: server.starttls() server.login(smtp_user, smtp_pass) server.send_message(msg) return {"ok": True, "message": f"Test-E-Mail an {email} gesendet"} except Exception as e: raise HTTPException(500, f"Fehler beim Senden: {str(e)}") # ── Export ──────────────────────────────────────────────────────────────────── @app.get("/api/export/csv") def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Export all data as CSV.""" pid = get_pid(x_profile_id) # Check export permission with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,)) prof = cur.fetchone() if not prof or not prof['export_enabled']: raise HTTPException(403, "Export ist für dieses Profil deaktiviert") # Build CSV output = io.StringIO() writer = csv.writer(output) # Header writer.writerow(["Typ", "Datum", "Wert", "Details"]) # Weight with get_db() as conn: 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"{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:{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"{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"{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"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"]) output.seek(0) return StreamingResponse( iter([output.getvalue()]), media_type="text/csv", headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.csv"} ) @app.get("/api/export/json") def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Export all data as JSON.""" pid = get_pid(x_profile_id) # Check export permission with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,)) prof = cur.fetchone() if not prof or not prof['export_enabled']: raise HTTPException(403, "Export ist für dieses Profil deaktiviert") # Collect all data data = {} with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) data['profile'] = r2d(cur.fetchone()) cur.execute("SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,)) data['weight'] = [r2d(r) for r in cur.fetchall()] cur.execute("SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,)) data['circumferences'] = [r2d(r) for r in cur.fetchall()] cur.execute("SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,)) data['caliper'] = [r2d(r) for r in cur.fetchall()] cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,)) data['nutrition'] = [r2d(r) for r in cur.fetchall()] cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,)) data['activity'] = [r2d(r) for r in cur.fetchall()] cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,)) data['insights'] = [r2d(r) for r in cur.fetchall()] 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", headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.json"} ) @app.get("/api/export/zip") def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Export all data as ZIP (CSV + JSON + photos) per specification.""" pid = get_pid(x_profile_id) # Check export permission & get profile with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) prof = r2d(cur.fetchone()) if not prof or not prof.get('export_enabled'): raise HTTPException(403, "Export ist für dieses Profil deaktiviert") # Helper: CSV writer with UTF-8 BOM + semicolon def write_csv(zf, filename, rows, columns): if not rows: return output = io.StringIO() writer = csv.writer(output, delimiter=';') writer.writerow(columns) for r in rows: writer.writerow([ '' if r.get(col) is None else (float(r[col]) if isinstance(r.get(col), Decimal) else r[col]) for col in columns ]) # UTF-8 with BOM for Excel csv_bytes = '\ufeff'.encode('utf-8') + output.getvalue().encode('utf-8') zf.writestr(f"data/{filename}", csv_bytes) # Create ZIP zip_buffer = io.BytesIO() export_date = datetime.now().strftime('%Y-%m-%d') profile_name = prof.get('name', 'export') with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: with get_db() as conn: cur = get_cursor(conn) # 1. README.txt readme = f"""Mitai Jinkendo – Datenexport Version: 2 Exportiert am: {export_date} Profil: {profile_name} Inhalt: - profile.json: Profildaten und Einstellungen - data/*.csv: Messdaten (Semikolon-getrennt, UTF-8) - insights/: KI-Auswertungen (JSON) - photos/: Progress-Fotos (JPEG) Import: Dieser Export kann in Mitai Jinkendo unter Einstellungen → Import → "Mitai Backup importieren" wieder eingespielt werden. Format-Version 2 (ab v9b): Alle CSV-Dateien sind UTF-8 mit BOM kodiert. Trennzeichen: Semikolon (;) Datumsformat: YYYY-MM-DD """ zf.writestr("README.txt", readme.encode('utf-8')) # 2. profile.json (ohne Passwort-Hash) cur.execute("SELECT COUNT(*) as c FROM weight_log WHERE profile_id=%s", (pid,)) w_count = cur.fetchone()['c'] cur.execute("SELECT COUNT(*) as c FROM nutrition_log WHERE profile_id=%s", (pid,)) n_count = cur.fetchone()['c'] cur.execute("SELECT COUNT(*) as c FROM activity_log WHERE profile_id=%s", (pid,)) a_count = cur.fetchone()['c'] cur.execute("SELECT COUNT(*) as c FROM photos WHERE profile_id=%s", (pid,)) p_count = cur.fetchone()['c'] profile_data = { "export_version": "2", "export_date": export_date, "app": "Mitai Jinkendo", "profile": { "name": prof.get('name'), "email": prof.get('email'), "sex": prof.get('sex'), "height": float(prof['height']) if prof.get('height') else None, "birth_year": prof['dob'].year if prof.get('dob') else None, "goal_weight": float(prof['goal_weight']) if prof.get('goal_weight') else None, "goal_bf_pct": float(prof['goal_bf_pct']) if prof.get('goal_bf_pct') else None, "avatar_color": prof.get('avatar_color'), "auth_type": prof.get('auth_type'), "session_days": prof.get('session_days'), "ai_enabled": prof.get('ai_enabled'), "tier": prof.get('tier') }, "stats": { "weight_entries": w_count, "nutrition_entries": n_count, "activity_entries": a_count, "photos": p_count } } zf.writestr("profile.json", json.dumps(profile_data, indent=2, ensure_ascii=False).encode('utf-8')) # 3. data/weight.csv cur.execute("SELECT id, date, weight, note, source, created FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,)) write_csv(zf, "weight.csv", [r2d(r) for r in cur.fetchall()], ['id','date','weight','note','source','created']) # 4. data/circumferences.csv cur.execute("SELECT id, date, c_waist, c_hip, c_chest, c_neck, c_arm, c_thigh, c_calf, notes, created FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,)) rows = [r2d(r) for r in cur.fetchall()] # Rename columns to match spec for r in rows: r['waist'] = r.pop('c_waist', None) r['hip'] = r.pop('c_hip', None) r['chest'] = r.pop('c_chest', None) r['neck'] = r.pop('c_neck', None) r['upper_arm'] = r.pop('c_arm', None) r['thigh'] = r.pop('c_thigh', None) r['calf'] = r.pop('c_calf', None) r['forearm'] = None # not tracked r['note'] = r.pop('notes', None) write_csv(zf, "circumferences.csv", rows, ['id','date','waist','hip','chest','neck','upper_arm','thigh','calf','forearm','note','created']) # 5. data/caliper.csv cur.execute("SELECT id, date, sf_chest, sf_abdomen, sf_thigh, sf_triceps, sf_subscap, sf_suprailiac, sf_axilla, sf_method, body_fat_pct, notes, created FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,)) rows = [r2d(r) for r in cur.fetchall()] for r in rows: r['chest'] = r.pop('sf_chest', None) r['abdomen'] = r.pop('sf_abdomen', None) r['thigh'] = r.pop('sf_thigh', None) r['tricep'] = r.pop('sf_triceps', None) r['subscapular'] = r.pop('sf_subscap', None) r['suprailiac'] = r.pop('sf_suprailiac', None) r['midaxillary'] = r.pop('sf_axilla', None) r['method'] = r.pop('sf_method', None) r['bf_percent'] = r.pop('body_fat_pct', None) r['note'] = r.pop('notes', None) write_csv(zf, "caliper.csv", rows, ['id','date','chest','abdomen','thigh','tricep','subscapular','suprailiac','midaxillary','method','bf_percent','note','created']) # 6. data/nutrition.csv cur.execute("SELECT id, date, kcal, protein_g, fat_g, carbs_g, source, created FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,)) rows = [r2d(r) for r in cur.fetchall()] for r in rows: r['meal_name'] = '' # not tracked per meal r['protein'] = r.pop('protein_g', None) r['fat'] = r.pop('fat_g', None) r['carbs'] = r.pop('carbs_g', None) r['fiber'] = None # not tracked r['note'] = '' write_csv(zf, "nutrition.csv", rows, ['id','date','meal_name','kcal','protein','fat','carbs','fiber','note','source','created']) # 7. data/activity.csv cur.execute("SELECT id, date, activity_type, duration_min, kcal_active, hr_avg, hr_max, distance_km, notes, source, created FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,)) rows = [r2d(r) for r in cur.fetchall()] for r in rows: r['name'] = r['activity_type'] r['type'] = r.pop('activity_type', None) r['kcal'] = r.pop('kcal_active', None) r['heart_rate_avg'] = r.pop('hr_avg', None) r['heart_rate_max'] = r.pop('hr_max', None) r['note'] = r.pop('notes', None) write_csv(zf, "activity.csv", rows, ['id','date','name','type','duration_min','kcal','heart_rate_avg','heart_rate_max','distance_km','note','source','created']) # 8. insights/ai_insights.json cur.execute("SELECT id, scope, content, created FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,)) insights = [] for r in cur.fetchall(): rd = r2d(r) insights.append({ "id": rd['id'], "scope": rd['scope'], "created": rd['created'].isoformat() if hasattr(rd['created'], 'isoformat') else str(rd['created']), "result": rd['content'] }) if insights: zf.writestr("insights/ai_insights.json", json.dumps(insights, indent=2, ensure_ascii=False).encode('utf-8')) # 9. photos/ cur.execute("SELECT * FROM photos WHERE profile_id=%s ORDER BY date", (pid,)) photos = [r2d(r) for r in cur.fetchall()] for i, photo in enumerate(photos): photo_path = Path(PHOTOS_DIR) / photo['path'] if photo_path.exists(): filename = f"{photo.get('date') or export_date}_{i+1}{photo_path.suffix}" zf.write(photo_path, f"photos/{filename}") zip_buffer.seek(0) filename = f"mitai-export-{profile_name.replace(' ','-')}-{export_date}.zip" return StreamingResponse( iter([zip_buffer.getvalue()]), media_type="application/zip", headers={"Content-Disposition": f"attachment; filename={filename}"} ) # ── Import ZIP ────────────────────────────────────────────────── @app.post("/api/import/zip") async def import_zip( file: UploadFile = File(...), x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth) ): """ Import data from ZIP export file. - Validates export format - Imports missing entries only (ON CONFLICT DO NOTHING) - Imports photos - Returns import summary - Full rollback on error """ pid = get_pid(x_profile_id) # Read uploaded file content = await file.read() zip_buffer = io.BytesIO(content) try: with zipfile.ZipFile(zip_buffer, 'r') as zf: # 1. Validate profile.json if 'profile.json' not in zf.namelist(): raise HTTPException(400, "Ungültiger Export: profile.json fehlt") profile_data = json.loads(zf.read('profile.json').decode('utf-8')) export_version = profile_data.get('export_version', '1') # Stats tracker stats = { 'weight': 0, 'circumferences': 0, 'caliper': 0, 'nutrition': 0, 'activity': 0, 'photos': 0, 'insights': 0 } with get_db() as conn: cur = get_cursor(conn) try: # 2. Import weight.csv if 'data/weight.csv' in zf.namelist(): csv_data = zf.read('data/weight.csv').decode('utf-8-sig') reader = csv.DictReader(io.StringIO(csv_data), delimiter=';') for row in reader: cur.execute(""" INSERT INTO weight_log (profile_id, date, weight, note, source, created) VALUES (%s, %s, %s, %s, %s, %s) ON CONFLICT (profile_id, date) DO NOTHING """, ( pid, row['date'], float(row['weight']) if row['weight'] else None, row.get('note', ''), row.get('source', 'import'), row.get('created', datetime.now()) )) if cur.rowcount > 0: stats['weight'] += 1 # 3. Import circumferences.csv if 'data/circumferences.csv' in zf.namelist(): csv_data = zf.read('data/circumferences.csv').decode('utf-8-sig') reader = csv.DictReader(io.StringIO(csv_data), delimiter=';') for row in reader: # Map CSV columns to DB columns cur.execute(""" INSERT INTO circumference_log ( profile_id, date, c_waist, c_hip, c_chest, c_neck, c_arm, c_thigh, c_calf, notes, created ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (profile_id, date) DO NOTHING """, ( pid, row['date'], float(row['waist']) if row.get('waist') else None, float(row['hip']) if row.get('hip') else None, float(row['chest']) if row.get('chest') else None, float(row['neck']) if row.get('neck') else None, float(row['upper_arm']) if row.get('upper_arm') else None, float(row['thigh']) if row.get('thigh') else None, float(row['calf']) if row.get('calf') else None, row.get('note', ''), row.get('created', datetime.now()) )) if cur.rowcount > 0: stats['circumferences'] += 1 # 4. Import caliper.csv if 'data/caliper.csv' in zf.namelist(): csv_data = zf.read('data/caliper.csv').decode('utf-8-sig') reader = csv.DictReader(io.StringIO(csv_data), delimiter=';') for row in reader: cur.execute(""" INSERT INTO caliper_log ( profile_id, date, sf_chest, sf_abdomen, sf_thigh, sf_triceps, sf_subscap, sf_suprailiac, sf_axilla, sf_method, body_fat_pct, notes, created ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (profile_id, date) DO NOTHING """, ( pid, row['date'], float(row['chest']) if row.get('chest') else None, float(row['abdomen']) if row.get('abdomen') else None, float(row['thigh']) if row.get('thigh') else None, float(row['tricep']) if row.get('tricep') else None, float(row['subscapular']) if row.get('subscapular') else None, float(row['suprailiac']) if row.get('suprailiac') else None, float(row['midaxillary']) if row.get('midaxillary') else None, row.get('method', 'jackson3'), float(row['bf_percent']) if row.get('bf_percent') else None, row.get('note', ''), row.get('created', datetime.now()) )) if cur.rowcount > 0: stats['caliper'] += 1 # 5. Import nutrition.csv if 'data/nutrition.csv' in zf.namelist(): csv_data = zf.read('data/nutrition.csv').decode('utf-8-sig') reader = csv.DictReader(io.StringIO(csv_data), delimiter=';') for row in reader: cur.execute(""" INSERT INTO nutrition_log ( profile_id, date, kcal, protein_g, fat_g, carbs_g, source, created ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (profile_id, date) DO NOTHING """, ( pid, row['date'], float(row['kcal']) if row.get('kcal') else None, float(row['protein']) if row.get('protein') else None, float(row['fat']) if row.get('fat') else None, float(row['carbs']) if row.get('carbs') else None, row.get('source', 'import'), row.get('created', datetime.now()) )) if cur.rowcount > 0: stats['nutrition'] += 1 # 6. Import activity.csv if 'data/activity.csv' in zf.namelist(): csv_data = zf.read('data/activity.csv').decode('utf-8-sig') reader = csv.DictReader(io.StringIO(csv_data), delimiter=';') for row in reader: cur.execute(""" INSERT INTO activity_log ( profile_id, date, activity_type, duration_min, kcal_active, hr_avg, hr_max, distance_km, notes, source, created ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( pid, row['date'], row.get('type', 'Training'), float(row['duration_min']) if row.get('duration_min') else None, float(row['kcal']) if row.get('kcal') else None, float(row['heart_rate_avg']) if row.get('heart_rate_avg') else None, float(row['heart_rate_max']) if row.get('heart_rate_max') else None, float(row['distance_km']) if row.get('distance_km') else None, row.get('note', ''), row.get('source', 'import'), row.get('created', datetime.now()) )) if cur.rowcount > 0: stats['activity'] += 1 # 7. Import ai_insights.json if 'insights/ai_insights.json' in zf.namelist(): insights_data = json.loads(zf.read('insights/ai_insights.json').decode('utf-8')) for insight in insights_data: cur.execute(""" INSERT INTO ai_insights (profile_id, scope, content, created) VALUES (%s, %s, %s, %s) """, ( pid, insight['scope'], insight['result'], insight.get('created', datetime.now()) )) stats['insights'] += 1 # 8. Import photos photo_files = [f for f in zf.namelist() if f.startswith('photos/') and not f.endswith('/')] for photo_file in photo_files: # Extract date from filename (format: YYYY-MM-DD_N.jpg) filename = Path(photo_file).name parts = filename.split('_') photo_date = parts[0] if len(parts) > 0 else datetime.now().strftime('%Y-%m-%d') # Generate new ID and path photo_id = str(uuid.uuid4()) ext = Path(filename).suffix new_filename = f"{photo_id}{ext}" target_path = PHOTOS_DIR / new_filename # Check if photo already exists for this date cur.execute(""" SELECT id FROM photos WHERE profile_id = %s AND date = %s """, (pid, photo_date)) if cur.fetchone() is None: # Write photo file with open(target_path, 'wb') as f: f.write(zf.read(photo_file)) # Insert DB record cur.execute(""" INSERT INTO photos (id, profile_id, date, path, created) VALUES (%s, %s, %s, %s, %s) """, (photo_id, pid, photo_date, new_filename, datetime.now())) stats['photos'] += 1 # Commit transaction conn.commit() except Exception as e: # Rollback on any error conn.rollback() raise HTTPException(500, f"Import fehlgeschlagen: {str(e)}") return { "ok": True, "message": "Import erfolgreich", "stats": stats, "total": sum(stats.values()) } except zipfile.BadZipFile: raise HTTPException(400, "Ungültige ZIP-Datei") except Exception as e: raise HTTPException(500, f"Import-Fehler: {str(e)}")