2026-03-18 17:07:41 +01:00
import os , csv , io , uuid , json , zipfile
2026-03-16 13:35:11 +01:00
from pathlib import Path
from typing import Optional
from datetime import datetime
2026-03-18 21:52:57 +01:00
from decimal import Decimal
2026-03-16 13:35:11 +01:00
from fastapi import FastAPI , HTTPException , UploadFile , File , Header , Query , Depends
from fastapi . middleware . cors import CORSMiddleware
2026-03-18 17:07:41 +01:00
from fastapi . responses import StreamingResponse , FileResponse , Response
2026-03-16 13:35:11 +01:00
from pydantic import BaseModel
2026-03-18 08:27:33 +01:00
import aiofiles
2026-03-16 13:35:11 +01:00
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
2026-03-19 09:49:46 +01:00
from db import get_db , get_cursor , r2d , init_db
2026-03-19 09:51:25 +01:00
from auth import hash_pin , verify_pin , make_token , get_session , require_auth , require_auth_flexible , require_admin
2026-03-19 09:53:51 +01:00
from models import (
ProfileCreate , ProfileUpdate , WeightEntry , CircumferenceEntry ,
CaliperEntry , ActivityEntry , NutritionDay , LoginRequest ,
PasswordResetRequest , PasswordResetConfirm , AdminProfileUpdate
)
2026-03-18 08:27:33 +01:00
2026-03-16 13:35:11 +01:00
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 ' ]
2026-03-19 07:56:36 +01:00
@app.on_event ( " startup " )
async def startup_event ( ) :
""" Run migrations and initialization on startup. """
2026-03-19 08:26:09 +01:00
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
2026-03-19 07:56:36 +01:00
2026-03-16 13:35:11 +01:00
# ── 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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " SELECT id FROM profiles ORDER BY created LIMIT 1 " )
row = cur . fetchone ( )
2026-03-16 13:35:11 +01:00
if row : return row [ ' id ' ]
raise HTTPException ( 400 , " Kein Profil gefunden " )
# ── Models ────────────────────────────────────────────────────────────────────
# ── Profiles ──────────────────────────────────────────────────────────────────
from datetime import timedelta
2026-03-19 09:53:51 +01:00
# Models moved to models.py
2026-03-19 09:51:25 +01:00
# Auth functions moved to auth.py
2026-03-16 13:35:11 +01:00
@app.get ( " /api/profiles " )
def list_profiles ( session = Depends ( require_auth ) ) :
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " SELECT * FROM profiles ORDER BY created " )
rows = cur . fetchall ( )
2026-03-16 13:35:11 +01:00
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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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 ) """ ,
2026-03-16 13:35:11 +01:00
( pid , p . name , p . avatar_color , p . sex , p . dob , p . height , p . goal_weight , p . goal_bf_pct ) )
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " SELECT * FROM profiles WHERE id= %s " , ( pid , ) )
return r2d ( cur . fetchone ( ) )
2026-03-16 13:35:11 +01:00
@app.get ( " /api/profiles/ {pid} " )
def get_profile ( pid : str , session = Depends ( require_auth ) ) :
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " SELECT * FROM profiles WHERE id= %s " , ( pid , ) )
row = cur . fetchone ( )
2026-03-16 13:35:11 +01:00
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 ( )
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( f " UPDATE profiles SET { ' , ' . join ( f ' { k } =%s ' for k in data ) } WHERE id=%s " ,
2026-03-16 13:35:11 +01:00
list ( data . values ( ) ) + [ pid ] )
2026-03-18 08:27:33 +01:00
return get_profile ( pid , session )
2026-03-16 13:35:11 +01:00
@app.delete ( " /api/profiles/ {pid} " )
def delete_profile ( pid : str , session = Depends ( require_auth ) ) :
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 12:47:01 +01:00
cur . execute ( " SELECT COUNT(*) as count FROM profiles " )
count = cur . fetchone ( ) [ ' count ' ]
2026-03-16 13:35:11 +01:00
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 ' ] :
2026-03-18 08:27:33 +01:00
cur . execute ( f " DELETE FROM { table } WHERE profile_id=%s " , ( pid , ) )
cur . execute ( " DELETE FROM profiles WHERE id= %s " , ( pid , ) )
2026-03-16 13:35:11 +01:00
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 )
2026-03-18 08:27:33 +01:00
return get_profile ( pid , session )
2026-03-16 13:35:11 +01:00
@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 )
2026-03-18 08:27:33 +01:00
return update_profile ( pid , p , session )
2026-03-16 13:35:11 +01:00
# ── 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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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 ( ) ]
2026-03-16 13:35:11 +01:00
@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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " SELECT id FROM weight_log WHERE profile_id= %s AND date= %s " , ( pid , e . date ) )
ex = cur . fetchone ( )
2026-03-16 13:35:11 +01:00
if ex :
2026-03-18 08:27:33 +01:00
cur . execute ( " UPDATE weight_log SET weight= %s ,note= %s WHERE id= %s " , ( e . weight , e . note , ex [ ' id ' ] ) )
2026-03-16 13:35:11 +01:00
wid = ex [ ' id ' ]
else :
wid = str ( uuid . uuid4 ( ) )
2026-03-18 08:27:33 +01:00
cur . execute ( " INSERT INTO weight_log (id,profile_id,date,weight,note,created) VALUES ( %s , %s , %s , %s , %s ,CURRENT_TIMESTAMP) " ,
2026-03-16 13:35:11 +01:00
( 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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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 ) )
2026-03-16 13:35:11 +01:00
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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " DELETE FROM weight_log WHERE id= %s AND profile_id= %s " , ( wid , pid ) )
2026-03-16 13:35:11 +01:00
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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " SELECT date,weight FROM weight_log WHERE profile_id= %s ORDER BY date DESC LIMIT 90 " , ( pid , ) )
rows = cur . fetchall ( )
2026-03-16 13:35:11 +01:00
if not rows : return { " count " : 0 , " latest " : None , " prev " : None , " min " : None , " max " : None , " avg_7d " : None }
2026-03-18 21:52:57 +01:00
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 ,
2026-03-16 13:35:11 +01:00
" 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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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 ( ) ]
2026-03-16 13:35:11 +01:00
@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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " SELECT id FROM circumference_log WHERE profile_id= %s AND date= %s " , ( pid , e . date ) )
ex = cur . fetchone ( )
2026-03-16 13:35:11 +01:00
d = e . model_dump ( )
if ex :
eid = ex [ ' id ' ]
2026-03-18 08:27:33 +01:00
sets = ' , ' . join ( f " { k } =%s " for k in d if k != ' date ' )
cur . execute ( f " UPDATE circumference_log SET { sets } WHERE id=%s " ,
2026-03-16 13:35:11 +01:00
[ v for k , v in d . items ( ) if k != ' date ' ] + [ eid ] )
else :
eid = str ( uuid . uuid4 ( ) )
2026-03-18 08:27:33 +01:00
cur . execute ( """ INSERT INTO circumference_log
2026-03-16 13:35:11 +01:00
( id , profile_id , date , c_neck , c_chest , c_waist , c_belly , c_hip , c_thigh , c_calf , c_arm , notes , photo_id , created )
2026-03-18 08:27:33 +01:00
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , CURRENT_TIMESTAMP ) """ ,
2026-03-16 13:35:11 +01:00
( 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 ( )
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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 ] )
2026-03-16 13:35:11 +01:00
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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " DELETE FROM circumference_log WHERE id= %s AND profile_id= %s " , ( eid , pid ) )
2026-03-16 13:35:11 +01:00
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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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 ( ) ]
2026-03-16 13:35:11 +01:00
@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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " SELECT id FROM caliper_log WHERE profile_id= %s AND date= %s " , ( pid , e . date ) )
ex = cur . fetchone ( )
2026-03-16 13:35:11 +01:00
d = e . model_dump ( )
if ex :
eid = ex [ ' id ' ]
2026-03-18 08:27:33 +01:00
sets = ' , ' . join ( f " { k } =%s " for k in d if k != ' date ' )
cur . execute ( f " UPDATE caliper_log SET { sets } WHERE id=%s " ,
2026-03-16 13:35:11 +01:00
[ v for k , v in d . items ( ) if k != ' date ' ] + [ eid ] )
else :
eid = str ( uuid . uuid4 ( ) )
2026-03-18 08:27:33 +01:00
cur . execute ( """ INSERT INTO caliper_log
2026-03-16 13:35:11 +01:00
( 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 )
2026-03-18 08:27:33 +01:00
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , CURRENT_TIMESTAMP ) """ ,
2026-03-16 13:35:11 +01:00
( 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 ( )
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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 ] )
2026-03-16 13:35:11 +01:00
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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " DELETE FROM caliper_log WHERE id= %s AND profile_id= %s " , ( eid , pid ) )
2026-03-16 13:35:11 +01:00
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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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 ( ) ]
2026-03-16 13:35:11 +01:00
@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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( """ INSERT INTO activity_log
2026-03-16 13:35:11 +01:00
( 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 )
2026-03-18 08:27:33 +01:00
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , CURRENT_TIMESTAMP ) """ ,
2026-03-16 13:35:11 +01:00
( 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 ( )
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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 ] )
2026-03-16 13:35:11 +01:00
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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " DELETE FROM activity_log WHERE id= %s AND profile_id= %s " , ( eid , pid ) )
2026-03-16 13:35:11 +01:00
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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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 ( ) ]
2026-03-16 13:35:11 +01:00
if not rows : return { " count " : 0 , " total_kcal " : 0 , " total_min " : 0 , " by_type " : { } }
2026-03-18 21:52:57 +01:00
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 )
2026-03-16 13:35:11 +01:00
by_type = { }
for r in rows :
t = r [ ' activity_type ' ] ; by_type . setdefault ( t , { ' count ' : 0 , ' kcal ' : 0 , ' min ' : 0 } )
2026-03-18 21:52:57 +01:00
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 )
2026-03-16 13:35:11 +01:00
return { " count " : len ( rows ) , " total_kcal " : round ( total_kcal ) , " total_min " : round ( total_min ) , " by_type " : by_type }
@app.post ( " /api/activity/import-csv " )
2026-03-18 08:27:33 +01:00
async def import_activity_csv ( file : UploadFile = File ( . . . ) , x_profile_id : Optional [ str ] = Header ( default = None ) , session : dict = Depends ( require_auth ) ) :
2026-03-16 13:35:11 +01:00
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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-16 13:35:11 +01:00
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 :
2026-03-18 08:27:33 +01:00
cur . execute ( """ INSERT INTO activity_log
2026-03-16 13:35:11 +01:00
( id , profile_id , date , start_time , end_time , activity_type , duration_min , kcal_active , kcal_resting ,
hr_avg , hr_max , distance_km , source , created )
2026-03-18 08:27:33 +01:00
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , ' apple_health ' , CURRENT_TIMESTAMP ) """ ,
2026-03-16 13:35:11 +01:00
( 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 = " " ,
2026-03-18 08:27:33 +01:00
x_profile_id : Optional [ str ] = Header ( default = None ) , session : dict = Depends ( require_auth ) ) :
2026-03-16 13:35:11 +01:00
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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " INSERT INTO photos (id,profile_id,date,path,created) VALUES ( %s , %s , %s , %s ,CURRENT_TIMESTAMP) " ,
( fid , pid , date , str ( path ) ) )
2026-03-16 13:35:11 +01:00
return { " id " : fid , " date " : date }
@app.get ( " /api/photos/ {fid} " )
2026-03-18 22:48:40 +01:00
def get_photo ( fid : str , session : dict = Depends ( require_auth_flexible ) ) :
""" Get photo by ID. Auth via header or query param (for <img> tags). """
2026-03-16 13:35:11 +01:00
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " SELECT path FROM photos WHERE id= %s " , ( fid , ) )
row = cur . fetchone ( )
2026-03-18 22:48:40 +01:00
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 )
2026-03-16 13:35:11 +01:00
@app.get ( " /api/photos " )
2026-03-18 08:27:33 +01:00
def list_photos ( x_profile_id : Optional [ str ] = Header ( default = None ) , session : dict = Depends ( require_auth ) ) :
2026-03-16 13:35:11 +01:00
pid = get_pid ( x_profile_id )
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute (
" SELECT * FROM photos WHERE profile_id= %s ORDER BY created DESC LIMIT 100 " , ( pid , ) )
return [ r2d ( r ) for r in cur . fetchall ( ) ]
2026-03-16 13:35:11 +01:00
# ── Nutrition ─────────────────────────────────────────────────────────────────
def _pf ( s ) :
try : return float ( str ( s ) . replace ( ' , ' , ' . ' ) . strip ( ) )
except : return 0.0
@app.post ( " /api/nutrition/import-csv " )
2026-03-18 08:27:33 +01:00
async def import_nutrition_csv ( file : UploadFile = File ( . . . ) , x_profile_id : Optional [ str ] = Header ( default = None ) , session : dict = Depends ( require_auth ) ) :
2026-03-16 13:35:11 +01:00
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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-16 13:35:11 +01:00
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 )
2026-03-18 08:27:33 +01:00
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 " ,
2026-03-16 13:35:11 +01:00
( kcal , prot , fat , carbs , pid , iso ) )
else :
2026-03-18 08:27:33 +01:00
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) " ,
2026-03-16 13:35:11 +01:00
( 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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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 ( ) ]
2026-03-16 13:35:11 +01:00
@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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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 ' ] )
2026-03-16 13:35:11 +01:00
all_dates = sorted ( set ( list ( nutr ) + list ( wlog ) ) )
mi , last_cal , cal_by_date = 0 , { } , { }
for d in all_dates :
while mi < len ( cals ) and cals [ mi ] [ ' date ' ] < = d : last_cal = cals [ mi ] ; mi + = 1
if last_cal : cal_by_date [ d ] = last_cal
result = [ ]
for d in all_dates :
if d not in nutr and d not in wlog : continue
row = { ' date ' : d }
2026-03-18 21:52:57 +01:00
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 ] )
2026-03-16 13:35:11 +01:00
if d in cal_by_date :
2026-03-18 21:52:57 +01:00
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
2026-03-16 13:35:11 +01:00
result . append ( row )
return result
@app.get ( " /api/nutrition/weekly " )
def nutrition_weekly ( weeks : int = 16 , x_profile_id : Optional [ str ] = Header ( default = None ) , session : dict = Depends ( require_auth ) ) :
pid = get_pid ( x_profile_id )
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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 ( ) ]
2026-03-16 13:35:11 +01:00
if not rows : return [ ]
wm = { }
for d in rows :
wk = datetime . strptime ( d [ ' date ' ] , ' % Y- % m- %d ' ) . strftime ( ' % Y-W % V ' )
wm . setdefault ( wk , [ ] ) . append ( d )
result = [ ]
for wk in sorted ( wm ) :
en = wm [ wk ] ; n = len ( en )
2026-03-18 21:52:57 +01:00
def avg ( k ) : return round ( sum ( float ( e . get ( k ) or 0 ) for e in en ) / n , 1 )
2026-03-16 13:35:11 +01:00
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
# ── Stats ─────────────────────────────────────────────────────────────────────
@app.get ( " /api/stats " )
def get_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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 12:47:01 +01:00
cur . execute ( " SELECT COUNT(*) as count FROM weight_log WHERE profile_id= %s " , ( pid , ) )
weight_count = cur . fetchone ( ) [ ' count ' ]
cur . execute ( " SELECT COUNT(*) as count FROM circumference_log WHERE profile_id= %s " , ( pid , ) )
circ_count = cur . fetchone ( ) [ ' count ' ]
cur . execute ( " SELECT COUNT(*) as count FROM caliper_log WHERE profile_id= %s " , ( pid , ) )
caliper_count = cur . fetchone ( ) [ ' count ' ]
cur . execute ( " SELECT COUNT(*) as count FROM nutrition_log WHERE profile_id= %s " , ( pid , ) )
nutrition_count = cur . fetchone ( ) [ ' count ' ]
cur . execute ( " SELECT COUNT(*) as count FROM activity_log WHERE profile_id= %s " , ( pid , ) )
activity_count = cur . fetchone ( ) [ ' count ' ]
2026-03-16 13:35:11 +01:00
return {
2026-03-18 08:27:33 +01:00
" weight_count " : weight_count ,
" circ_count " : circ_count ,
" caliper_count " : caliper_count ,
" nutrition_count " : nutrition_count ,
" activity_count " : activity_count
2026-03-16 13:35:11 +01:00
}
2026-03-18 08:27:33 +01:00
# ── AI Insights ───────────────────────────────────────────────────────────────
import httpx , json
2026-03-16 13:35:11 +01:00
2026-03-18 15:26:57 +01:00
@app.get ( " /api/insights " )
def get_all_insights ( x_profile_id : Optional [ str ] = Header ( default = None ) , session : dict = Depends ( require_auth ) ) :
""" Get all AI insights for profile. """
pid = get_pid ( x_profile_id )
with get_db ( ) as conn :
cur = get_cursor ( conn )
cur . execute ( " SELECT * FROM ai_insights WHERE profile_id= %s ORDER BY created DESC " , ( pid , ) )
rows = cur . fetchall ( )
return [ r2d ( r ) for r in rows ]
2026-03-18 12:54:25 +01:00
@app.get ( " /api/insights/latest " )
def get_latest_insights ( x_profile_id : Optional [ str ] = Header ( default = None ) , session : dict = Depends ( require_auth ) ) :
""" Get latest AI insights across all scopes. """
pid = get_pid ( x_profile_id )
with get_db ( ) as conn :
cur = get_cursor ( conn )
cur . execute ( " SELECT * FROM ai_insights WHERE profile_id= %s ORDER BY created DESC LIMIT 10 " , ( pid , ) )
rows = cur . fetchall ( )
return [ r2d ( r ) for r in rows ]
2026-03-18 08:27:33 +01:00
@app.get ( " /api/ai/insights/ {scope} " )
def get_ai_insight ( scope : str , x_profile_id : Optional [ str ] = Header ( default = None ) , session : dict = Depends ( require_auth ) ) :
2026-03-16 13:35:11 +01:00
pid = get_pid ( x_profile_id )
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " SELECT * FROM ai_insights WHERE profile_id= %s AND scope= %s ORDER BY created DESC LIMIT 1 " , ( pid , scope ) )
row = cur . fetchone ( )
if not row : return None
return r2d ( row )
2026-03-16 13:35:11 +01:00
2026-03-18 15:26:57 +01:00
@app.delete ( " /api/insights/ {insight_id} " )
def delete_insight_by_id ( insight_id : str , x_profile_id : Optional [ str ] = Header ( default = None ) , session : dict = Depends ( require_auth ) ) :
""" Delete a specific insight by ID. """
pid = get_pid ( x_profile_id )
with get_db ( ) as conn :
cur = get_cursor ( conn )
cur . execute ( " DELETE FROM ai_insights WHERE id= %s AND profile_id= %s " , ( insight_id , pid ) )
return { " ok " : True }
2026-03-18 08:27:33 +01:00
@app.delete ( " /api/ai/insights/ {scope} " )
def delete_ai_insight ( scope : str , x_profile_id : Optional [ str ] = Header ( default = None ) , session : dict = Depends ( require_auth ) ) :
2026-03-16 13:35:11 +01:00
pid = get_pid ( x_profile_id )
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " DELETE FROM ai_insights WHERE profile_id= %s AND scope= %s " , ( pid , scope ) )
return { " ok " : True }
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
def check_ai_limit ( pid : str ) :
""" Check if profile has reached daily AI limit. Returns (allowed, limit, used). """
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " SELECT ai_enabled, ai_limit_day FROM profiles WHERE id= %s " , ( pid , ) )
prof = cur . fetchone ( )
if not prof or not prof [ ' ai_enabled ' ] :
raise HTTPException ( 403 , " KI ist für dieses Profil deaktiviert " )
limit = prof [ ' ai_limit_day ' ]
if limit is None :
return ( True , None , 0 )
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
if used > = 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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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
2026-03-16 13:35:11 +01:00
}
2026-03-18 08:27:33 +01:00
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
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
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 ' ]
2026-03-16 13:35:11 +01:00
vars = {
2026-03-18 08:27:33 +01:00
" name " : prof . get ( ' name ' , ' Nutzer ' ) ,
" geschlecht " : " männlich " if prof . get ( ' sex ' ) == ' m ' else " weiblich " ,
" height " : prof . get ( ' height ' , 178 ) ,
2026-03-18 21:52:57 +01:00
" 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 " ,
2026-03-16 13:35:11 +01:00
}
2026-03-18 08:27:33 +01:00
# 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 "
2026-03-16 13:35:11 +01:00
else :
2026-03-18 08:27:33 +01:00
vars [ ' age ' ] = " unbekannt "
# Weight trend summary
if len ( weight ) > = 2 :
recent = weight [ : 30 ]
2026-03-18 21:44:10 +01:00
delta = float ( recent [ 0 ] [ ' weight ' ] ) - float ( recent [ - 1 ] [ ' weight ' ] )
2026-03-18 08:27:33 +01:00
vars [ ' weight_trend ' ] = f " { len ( recent ) } Einträge, Δ30d: { delta : +.1f } kg "
2026-03-16 13:35:11 +01:00
else :
2026-03-18 08:27:33 +01:00
vars [ ' weight_trend ' ] = " zu wenig Daten "
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
# Caliper summary
if caliper :
c = caliper [ 0 ]
2026-03-18 21:52:57 +01:00
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 ' , ' ? ' ) } "
2026-03-16 13:35:11 +01:00
else :
2026-03-18 08:27:33 +01:00
vars [ ' caliper_summary ' ] = " keine Daten "
# Circumference summary
if circ :
c = circ [ 0 ]
parts = [ ]
for k in [ ' c_waist ' , ' c_belly ' , ' c_hip ' ] :
2026-03-18 21:52:57 +01:00
if c . get ( k ) : parts . append ( f " { k . split ( ' _ ' ) [ 1 ] } : { float ( c [ k ] ) } cm " )
2026-03-18 08:27:33 +01:00
vars [ ' circ_summary ' ] = " , " . join ( parts ) if parts else " keine Daten "
else :
vars [ ' circ_summary ' ] = " keine Daten "
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
# Nutrition summary
2026-03-16 13:35:11 +01:00
if nutrition :
2026-03-18 08:27:33 +01:00
n = len ( nutrition )
2026-03-18 21:44:10 +01:00
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
2026-03-18 08:27:33 +01:00
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 )
2026-03-18 21:44:10 +01:00
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 )
2026-03-16 13:35:11 +01:00
else :
2026-03-18 08:27:33 +01:00
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
2026-03-18 21:44:10 +01:00
w = float ( w ) # Convert Decimal to float for math operations
2026-03-18 08:27:33 +01:00
vars [ ' protein_ziel_low ' ] = round ( w * 1.6 )
vars [ ' protein_ziel_high ' ] = round ( w * 2.2 )
# Activity summary
if activity :
n = len ( activity )
2026-03-18 21:44:10 +01:00
total_kcal = sum ( float ( a . get ( ' kcal_active ' , 0 ) or 0 ) for a in activity )
2026-03-18 08:27:33 +01:00
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 "
2026-03-16 13:35:11 +01:00
else :
2026-03-18 08:27:33 +01:00
vars [ ' activity_summary ' ] = " keine Daten "
vars [ ' activity_detail ' ] = " keine Daten "
vars [ ' activity_kcal_summary ' ] = " keine Daten "
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
return vars
2026-03-16 13:35:11 +01:00
2026-03-18 17:07:41 +01:00
@app.post ( " /api/insights/run/ {slug} " )
2026-03-18 08:27:33 +01:00
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 )
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
# Get prompt template
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 13:55:14 +01:00
cur . execute ( " SELECT * FROM ai_prompts WHERE slug= %s AND active=true " , ( slug , ) )
2026-03-18 08:27:33 +01:00
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
2026-03-16 13:35:11 +01:00
import anthropic
client = anthropic . Anthropic ( api_key = ANTHROPIC_KEY )
2026-03-18 08:27:33 +01:00
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 " )
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
# Save insight
2026-03-16 13:35:11 +01:00
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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 ) )
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
inc_ai_usage ( pid )
return { " scope " : slug , " content " : content }
2026-03-16 13:35:11 +01:00
2026-03-18 17:07:41 +01:00
@app.post ( " /api/insights/pipeline " )
2026-03-18 08:27:33 +01:00
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 )
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
data = _get_profile_data ( pid )
vars = _prepare_template_vars ( data )
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
# Stage 1: Parallel JSON analyses
2026-03-16 13:35:11 +01:00
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 13:55:14 +01:00
cur . execute ( " SELECT slug, template FROM ai_prompts WHERE slug LIKE ' pipeline_ % ' AND slug NOT IN ( ' pipeline_synthesis ' , ' pipeline_goals ' ) AND active=true " )
2026-03-18 08:27:33 +01:00
stage1_prompts = [ r2d ( r ) for r in cur . fetchall ( ) ]
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
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
2026-03-16 13:35:11 +01:00
try :
2026-03-18 08:27:33 +01:00
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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 13:55:14 +01:00
cur . execute ( " SELECT template FROM ai_prompts WHERE slug= ' pipeline_synthesis ' AND active=true " )
2026-03-18 08:27:33 +01:00
synth_row = cur . fetchone ( )
if not synth_row :
raise HTTPException ( 500 , " Pipeline synthesis prompt not found " )
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
synth_prompt = _render_template ( synth_row [ ' template ' ] , vars )
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 13:55:14 +01:00
cur . execute ( " SELECT template FROM ai_prompts WHERE slug= ' pipeline_goals ' AND active=true " )
2026-03-18 08:27:33 +01:00
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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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 }
2026-03-18 13:55:14 +01:00
@app.get ( " /api/prompts " )
2026-03-18 08:27:33 +01:00
def list_prompts ( session : dict = Depends ( require_auth ) ) :
2026-03-19 06:28:48 +01:00
"""
List AI prompts .
- Admins : see ALL prompts ( including pipeline and inactive )
- Users : see only active single - analysis prompts
"""
2026-03-18 08:27:33 +01:00
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-19 06:28:48 +01:00
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 " )
2026-03-18 08:27:33 +01:00
return [ r2d ( r ) for r in cur . fetchall ( ) ]
2026-03-18 17:07:41 +01:00
@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 ' )
2026-03-19 07:27:56 +01:00
# Convert to boolean (accepts true/false, 1/0)
values . append ( bool ( data [ ' active ' ] ) )
2026-03-18 17:07:41 +01:00
if updates :
cur . execute ( f " UPDATE ai_prompts SET { ' , ' . join ( updates ) } , updated=CURRENT_TIMESTAMP WHERE id=%s " ,
values + [ prompt_id ] )
return { " ok " : True }
2026-03-18 08:27:33 +01:00
@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 )
2026-03-16 13:35:11 +01:00
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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 ( ) ]
2026-03-16 13:35:11 +01:00
return {
2026-03-18 08:27:33 +01:00
" limit " : limit ,
" used_today " : used ,
" remaining " : ( limit - used ) if limit else None ,
" history " : history
2026-03-16 13:35:11 +01:00
}
# ── Auth ──────────────────────────────────────────────────────────────────────
@app.post ( " /api/auth/login " )
@limiter.limit ( " 5/minute " )
2026-03-18 08:27:33 +01:00
async def login ( req : LoginRequest , request : Request ) :
""" Login with email + password. """
2026-03-16 13:35:11 +01:00
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " SELECT * FROM profiles WHERE email= %s " , ( req . email . lower ( ) . strip ( ) , ) )
prof = cur . fetchone ( )
if not prof :
raise HTTPException ( 401 , " Ungültige Zugangsdaten " )
2026-03-16 13:35:11 +01:00
# Verify password
2026-03-18 08:27:33 +01:00
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 ' ] ) )
2026-03-16 13:35:11 +01:00
# Create session
token = make_token ( )
2026-03-18 08:27:33 +01:00
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 ( ) ) )
2026-03-16 13:35:11 +01:00
return {
" token " : token ,
2026-03-18 08:27:33 +01:00
" profile_id " : prof [ ' id ' ] ,
" name " : prof [ ' name ' ] ,
" role " : prof [ ' role ' ] ,
" expires_at " : expires . isoformat ( )
2026-03-16 13:35:11 +01:00
}
@app.post ( " /api/auth/logout " )
def logout ( x_auth_token : Optional [ str ] = Header ( default = None ) ) :
2026-03-18 08:27:33 +01:00
""" Logout (delete session). """
2026-03-16 13:35:11 +01:00
if x_auth_token :
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " DELETE FROM sessions WHERE token= %s " , ( x_auth_token , ) )
2026-03-16 13:35:11 +01:00
return { " ok " : True }
@app.get ( " /api/auth/me " )
2026-03-18 08:27:33 +01:00
def get_me ( session : dict = Depends ( require_auth ) ) :
""" Get current user info. """
pid = session [ ' profile_id ' ]
return get_profile ( pid , session )
2026-03-16 13:35:11 +01:00
2026-03-18 12:54:25 +01:00
@app.get ( " /api/auth/status " )
def auth_status ( ) :
""" Health check endpoint. """
return { " status " : " ok " , " service " : " mitai-jinkendo " , " version " : " v9b " }
2026-03-19 10:13:07 +01:00
@app.put ( " /api/auth/pin " )
2026-03-18 17:07:41 +01:00
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 " )
2026-03-18 08:27:33 +01:00
@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 :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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. " }
2026-03-16 13:35:11 +01:00
2026-03-18 17:07:41 +01:00
@app.post ( " /api/auth/reset-password " )
2026-03-18 08:27:33 +01:00
def password_reset_confirm ( req : PasswordResetConfirm ) :
""" Confirm password reset with token. """
2026-03-16 13:35:11 +01:00
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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 ─────────────────────────────────────────────────────────────────────
2026-03-16 13:35:11 +01:00
@app.get ( " /api/admin/profiles " )
2026-03-18 08:27:33 +01:00
def admin_list_profiles ( session : dict = Depends ( require_admin ) ) :
""" Admin: List all profiles with stats. """
2026-03-16 13:35:11 +01:00
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " SELECT * FROM profiles ORDER BY created " )
profs = [ r2d ( r ) for r in cur . fetchall ( ) ]
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
for p in profs :
pid = p [ ' id ' ]
2026-03-18 12:47:01 +01:00
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 ' ]
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
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
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
return profs
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
@app.put ( " /api/admin/profiles/ {pid} " )
def admin_update_profile ( pid : str , data : AdminProfileUpdate , session : dict = Depends ( require_admin ) ) :
""" Admin: Update profile settings. """
2026-03-16 13:35:11 +01:00
with get_db ( ) as conn :
2026-03-18 08:27:33 +01:00
updates = { k : v for k , v in data . model_dump ( ) . items ( ) if v is not None }
if not updates :
return { " ok " : True }
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( f " UPDATE profiles SET { ' , ' . join ( f ' { k } =%s ' for k in updates ) } WHERE id=%s " ,
list ( updates . values ( ) ) + [ pid ] )
2026-03-16 13:35:11 +01:00
return { " ok " : True }
2026-03-18 17:07:41 +01:00
@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 ) ) :
2026-03-18 08:27:33 +01:00
""" Admin: Send test email. """
2026-03-18 17:07:41 +01:00
email = data . get ( ' to ' , ' ' )
if not email :
raise HTTPException ( 400 , " E-Mail-Adresse fehlt " )
2026-03-16 13:35:11 +01:00
try :
2026-03-18 08:27:33 +01:00
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 " }
2026-03-16 13:35:11 +01:00
except Exception as e :
2026-03-18 08:27:33 +01:00
raise HTTPException ( 500 , f " Fehler beim Senden: { str ( e ) } " )
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
# ── 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 )
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
# Check export permission
2026-03-16 13:35:11 +01:00
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
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 " ] )
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
# Weight
2026-03-16 13:35:11 +01:00
with get_db ( ) as conn :
2026-03-18 12:42:46 +01:00
cur = get_cursor ( conn )
2026-03-18 08:27:33 +01:00
cur . execute ( " SELECT date, weight, note FROM weight_log WHERE profile_id= %s ORDER BY date " , ( pid , ) )
for r in cur . fetchall ( ) :
2026-03-18 21:52:57 +01:00
writer . writerow ( [ " Gewicht " , r [ ' date ' ] , f " { float ( r [ ' weight ' ] ) } kg " , r [ ' note ' ] or " " ] )
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
# 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 ( ) :
2026-03-18 21:52:57 +01:00
details = f " Taille: { float ( r [ ' c_waist ' ] ) } cm Bauch: { float ( r [ ' c_belly ' ] ) } cm Hüfte: { float ( r [ ' c_hip ' ] ) } cm "
2026-03-18 08:27:33 +01:00
writer . writerow ( [ " Umfänge " , r [ ' date ' ] , " " , details ] )
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
# 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 ( ) :
2026-03-18 21:52:57 +01:00
writer . writerow ( [ " Caliper " , r [ ' date ' ] , f " { float ( r [ ' body_fat_pct ' ] ) } % " , f " Magermasse: { float ( r [ ' lean_mass ' ] ) } kg " ] )
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
# Nutrition
cur . execute ( " SELECT date, kcal, protein_g FROM nutrition_log WHERE profile_id= %s ORDER BY date " , ( pid , ) )
for r in cur . fetchall ( ) :
2026-03-18 21:52:57 +01:00
writer . writerow ( [ " Ernährung " , r [ ' date ' ] , f " { float ( r [ ' kcal ' ] ) } kcal " , f " Protein: { float ( r [ ' protein_g ' ] ) } g " ] )
2026-03-16 13:35:11 +01:00
2026-03-18 08:27:33 +01:00
# 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 ( ) :
2026-03-18 21:52:57 +01:00
writer . writerow ( [ " Training " , r [ ' date ' ] , r [ ' activity_type ' ] , f " { float ( r [ ' duration_min ' ] ) } min { float ( r [ ' kcal_active ' ] ) } kcal " ] )
2026-03-18 08:27:33 +01:00
output . seek ( 0 )
return StreamingResponse (
iter ( [ output . getvalue ( ) ] ) ,
media_type = " text/csv " ,
headers = { " Content-Disposition " : f " attachment; filename=mitai-export- { pid } .csv " }
)
2026-03-18 17:07:41 +01:00
@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 ( ) ]
2026-03-18 21:52:57 +01:00
def decimal_handler ( obj ) :
if isinstance ( obj , Decimal ) :
return float ( obj )
return str ( obj )
json_str = json . dumps ( data , indent = 2 , default = decimal_handler )
2026-03-18 17:07:41 +01:00
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 ) ) :
feat: implement comprehensive ZIP export per v9c specification
Complete rewrite of ZIP export to match CLAUDE.md specification.
**Structure:**
- README.txt (format explanation)
- profile.json (no password hash, includes stats)
- data/*.csv (5 separate CSV files)
- insights/ai_insights.json
- photos/*.jpg
**CSV Format:**
- Delimiter: semicolon (;)
- Encoding: UTF-8 with BOM (Excel compatible)
- Date format: YYYY-MM-DD
- Decimal separator: dot (.)
- NULL values: empty string
- First row: header
**Files:**
- data/weight.csv (id, date, weight, note, source, created)
- data/circumferences.csv (8 measurement points)
- data/caliper.csv (skinfold measurements + bf%)
- data/nutrition.csv (kcal, protein, fat, carbs)
- data/activity.csv (type, duration, kcal, HR, distance)
**Filename:** mitai-export-{name}-{YYYY-MM-DD}.zip
Ready for import functionality (v9c).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:15:31 +01:00
""" Export all data as ZIP (CSV + JSON + photos) per specification. """
2026-03-18 17:07:41 +01:00
pid = get_pid ( x_profile_id )
feat: implement comprehensive ZIP export per v9c specification
Complete rewrite of ZIP export to match CLAUDE.md specification.
**Structure:**
- README.txt (format explanation)
- profile.json (no password hash, includes stats)
- data/*.csv (5 separate CSV files)
- insights/ai_insights.json
- photos/*.jpg
**CSV Format:**
- Delimiter: semicolon (;)
- Encoding: UTF-8 with BOM (Excel compatible)
- Date format: YYYY-MM-DD
- Decimal separator: dot (.)
- NULL values: empty string
- First row: header
**Files:**
- data/weight.csv (id, date, weight, note, source, created)
- data/circumferences.csv (8 measurement points)
- data/caliper.csv (skinfold measurements + bf%)
- data/nutrition.csv (kcal, protein, fat, carbs)
- data/activity.csv (type, duration, kcal, HR, distance)
**Filename:** mitai-export-{name}-{YYYY-MM-DD}.zip
Ready for import functionality (v9c).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:15:31 +01:00
# Check export permission & get profile
2026-03-18 17:07:41 +01:00
with get_db ( ) as conn :
cur = get_cursor ( conn )
feat: implement comprehensive ZIP export per v9c specification
Complete rewrite of ZIP export to match CLAUDE.md specification.
**Structure:**
- README.txt (format explanation)
- profile.json (no password hash, includes stats)
- data/*.csv (5 separate CSV files)
- insights/ai_insights.json
- photos/*.jpg
**CSV Format:**
- Delimiter: semicolon (;)
- Encoding: UTF-8 with BOM (Excel compatible)
- Date format: YYYY-MM-DD
- Decimal separator: dot (.)
- NULL values: empty string
- First row: header
**Files:**
- data/weight.csv (id, date, weight, note, source, created)
- data/circumferences.csv (8 measurement points)
- data/caliper.csv (skinfold measurements + bf%)
- data/nutrition.csv (kcal, protein, fat, carbs)
- data/activity.csv (type, duration, kcal, HR, distance)
**Filename:** mitai-export-{name}-{YYYY-MM-DD}.zip
Ready for import functionality (v9c).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:15:31 +01:00
cur . execute ( " SELECT * FROM profiles WHERE id= %s " , ( pid , ) )
prof = r2d ( cur . fetchone ( ) )
if not prof or not prof . get ( ' export_enabled ' ) :
2026-03-18 17:07:41 +01:00
raise HTTPException ( 403 , " Export ist für dieses Profil deaktiviert " )
feat: implement comprehensive ZIP export per v9c specification
Complete rewrite of ZIP export to match CLAUDE.md specification.
**Structure:**
- README.txt (format explanation)
- profile.json (no password hash, includes stats)
- data/*.csv (5 separate CSV files)
- insights/ai_insights.json
- photos/*.jpg
**CSV Format:**
- Delimiter: semicolon (;)
- Encoding: UTF-8 with BOM (Excel compatible)
- Date format: YYYY-MM-DD
- Decimal separator: dot (.)
- NULL values: empty string
- First row: header
**Files:**
- data/weight.csv (id, date, weight, note, source, created)
- data/circumferences.csv (8 measurement points)
- data/caliper.csv (skinfold measurements + bf%)
- data/nutrition.csv (kcal, protein, fat, carbs)
- data/activity.csv (type, duration, kcal, HR, distance)
**Filename:** mitai-export-{name}-{YYYY-MM-DD}.zip
Ready for import functionality (v9c).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:15:31 +01:00
# 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
2026-03-18 17:07:41 +01:00
zip_buffer = io . BytesIO ( )
feat: implement comprehensive ZIP export per v9c specification
Complete rewrite of ZIP export to match CLAUDE.md specification.
**Structure:**
- README.txt (format explanation)
- profile.json (no password hash, includes stats)
- data/*.csv (5 separate CSV files)
- insights/ai_insights.json
- photos/*.jpg
**CSV Format:**
- Delimiter: semicolon (;)
- Encoding: UTF-8 with BOM (Excel compatible)
- Date format: YYYY-MM-DD
- Decimal separator: dot (.)
- NULL values: empty string
- First row: header
**Files:**
- data/weight.csv (id, date, weight, note, source, created)
- data/circumferences.csv (8 measurement points)
- data/caliper.csv (skinfold measurements + bf%)
- data/nutrition.csv (kcal, protein, fat, carbs)
- data/activity.csv (type, duration, kcal, HR, distance)
**Filename:** mitai-export-{name}-{YYYY-MM-DD}.zip
Ready for import functionality (v9c).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:15:31 +01:00
export_date = datetime . now ( ) . strftime ( ' % Y- % m- %d ' )
profile_name = prof . get ( ' name ' , ' export ' )
2026-03-18 17:07:41 +01:00
with zipfile . ZipFile ( zip_buffer , ' w ' , zipfile . ZIP_DEFLATED ) as zf :
with get_db ( ) as conn :
cur = get_cursor ( conn )
feat: implement comprehensive ZIP export per v9c specification
Complete rewrite of ZIP export to match CLAUDE.md specification.
**Structure:**
- README.txt (format explanation)
- profile.json (no password hash, includes stats)
- data/*.csv (5 separate CSV files)
- insights/ai_insights.json
- photos/*.jpg
**CSV Format:**
- Delimiter: semicolon (;)
- Encoding: UTF-8 with BOM (Excel compatible)
- Date format: YYYY-MM-DD
- Decimal separator: dot (.)
- NULL values: empty string
- First row: header
**Files:**
- data/weight.csv (id, date, weight, note, source, created)
- data/circumferences.csv (8 measurement points)
- data/caliper.csv (skinfold measurements + bf%)
- data/nutrition.csv (kcal, protein, fat, carbs)
- data/activity.csv (type, duration, kcal, HR, distance)
**Filename:** mitai-export-{name}-{YYYY-MM-DD}.zip
Ready for import functionality (v9c).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:15:31 +01:00
# 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 ,
2026-03-18 22:41:51 +01:00
" birth_year " : prof [ ' dob ' ] . year if prof . get ( ' dob ' ) else None ,
feat: implement comprehensive ZIP export per v9c specification
Complete rewrite of ZIP export to match CLAUDE.md specification.
**Structure:**
- README.txt (format explanation)
- profile.json (no password hash, includes stats)
- data/*.csv (5 separate CSV files)
- insights/ai_insights.json
- photos/*.jpg
**CSV Format:**
- Delimiter: semicolon (;)
- Encoding: UTF-8 with BOM (Excel compatible)
- Date format: YYYY-MM-DD
- Decimal separator: dot (.)
- NULL values: empty string
- First row: header
**Files:**
- data/weight.csv (id, date, weight, note, source, created)
- data/circumferences.csv (8 measurement points)
- data/caliper.csv (skinfold measurements + bf%)
- data/nutrition.csv (kcal, protein, fat, carbs)
- data/activity.csv (type, duration, kcal, HR, distance)
**Filename:** mitai-export-{name}-{YYYY-MM-DD}.zip
Ready for import functionality (v9c).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:15:31 +01:00
" 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/
2026-03-18 17:07:41 +01:00
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 ( ) :
feat: implement comprehensive ZIP export per v9c specification
Complete rewrite of ZIP export to match CLAUDE.md specification.
**Structure:**
- README.txt (format explanation)
- profile.json (no password hash, includes stats)
- data/*.csv (5 separate CSV files)
- insights/ai_insights.json
- photos/*.jpg
**CSV Format:**
- Delimiter: semicolon (;)
- Encoding: UTF-8 with BOM (Excel compatible)
- Date format: YYYY-MM-DD
- Decimal separator: dot (.)
- NULL values: empty string
- First row: header
**Files:**
- data/weight.csv (id, date, weight, note, source, created)
- data/circumferences.csv (8 measurement points)
- data/caliper.csv (skinfold measurements + bf%)
- data/nutrition.csv (kcal, protein, fat, carbs)
- data/activity.csv (type, duration, kcal, HR, distance)
**Filename:** mitai-export-{name}-{YYYY-MM-DD}.zip
Ready for import functionality (v9c).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:15:31 +01:00
filename = f " { photo . get ( ' date ' ) or export_date } _ { i + 1 } { photo_path . suffix } "
zf . write ( photo_path , f " photos/ { filename } " )
2026-03-18 17:07:41 +01:00
zip_buffer . seek ( 0 )
feat: implement comprehensive ZIP export per v9c specification
Complete rewrite of ZIP export to match CLAUDE.md specification.
**Structure:**
- README.txt (format explanation)
- profile.json (no password hash, includes stats)
- data/*.csv (5 separate CSV files)
- insights/ai_insights.json
- photos/*.jpg
**CSV Format:**
- Delimiter: semicolon (;)
- Encoding: UTF-8 with BOM (Excel compatible)
- Date format: YYYY-MM-DD
- Decimal separator: dot (.)
- NULL values: empty string
- First row: header
**Files:**
- data/weight.csv (id, date, weight, note, source, created)
- data/circumferences.csv (8 measurement points)
- data/caliper.csv (skinfold measurements + bf%)
- data/nutrition.csv (kcal, protein, fat, carbs)
- data/activity.csv (type, duration, kcal, HR, distance)
**Filename:** mitai-export-{name}-{YYYY-MM-DD}.zip
Ready for import functionality (v9c).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:15:31 +01:00
filename = f " mitai-export- { profile_name . replace ( ' ' , ' - ' ) } - { export_date } .zip "
2026-03-18 17:07:41 +01:00
return StreamingResponse (
iter ( [ zip_buffer . getvalue ( ) ] ) ,
media_type = " application/zip " ,
feat: implement comprehensive ZIP export per v9c specification
Complete rewrite of ZIP export to match CLAUDE.md specification.
**Structure:**
- README.txt (format explanation)
- profile.json (no password hash, includes stats)
- data/*.csv (5 separate CSV files)
- insights/ai_insights.json
- photos/*.jpg
**CSV Format:**
- Delimiter: semicolon (;)
- Encoding: UTF-8 with BOM (Excel compatible)
- Date format: YYYY-MM-DD
- Decimal separator: dot (.)
- NULL values: empty string
- First row: header
**Files:**
- data/weight.csv (id, date, weight, note, source, created)
- data/circumferences.csv (8 measurement points)
- data/caliper.csv (skinfold measurements + bf%)
- data/nutrition.csv (kcal, protein, fat, carbs)
- data/activity.csv (type, duration, kcal, HR, distance)
**Filename:** mitai-export-{name}-{YYYY-MM-DD}.zip
Ready for import functionality (v9c).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:15:31 +01:00
headers = { " Content-Disposition " : f " attachment; filename= { filename } " }
2026-03-18 17:07:41 +01:00
)
2026-03-18 22:52:35 +01:00
# ── 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 ) } " )