Issue 1: Automatic training type mapping didn't work - Root cause: Only English workout names were mapped - Solution: Added 20+ German workout type mappings: - "Traditionelles Krafttraining" → hypertrophy - "Outdoor Spaziergang" → walk - "Innenräume Spaziergang" → walk - "Matrial Arts" → technique (handles typo) - "Cardio Dance" → dance - "Geist & Körper" → yoga - Plus: Laufen, Gehen, Radfahren, Schwimmen, etc. Issue 2: Reimporting CSV created duplicates without training types - Root cause: Import always did INSERT with new UUID, no duplicate check - Solution: Check if entry exists (profile_id + date + start_time) - If exists: UPDATE with new data + training type mapping - If new: INSERT as before - Handles multiple workouts per day (different start times) - "Skipped" count now includes updated entries Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
338 lines
14 KiB
Python
338 lines
14 KiB
Python
"""
|
|
Activity Tracking Endpoints for Mitai Jinkendo
|
|
|
|
Handles workout/activity logging, statistics, and Apple Health CSV import.
|
|
"""
|
|
import csv
|
|
import io
|
|
import uuid
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
|
|
|
from db import get_db, get_cursor, r2d
|
|
from auth import require_auth, check_feature_access, increment_feature_usage
|
|
from models import ActivityEntry
|
|
from routers.profiles import get_pid
|
|
from feature_logger import log_feature_usage
|
|
|
|
router = APIRouter(prefix="/api/activity", tags=["activity"])
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@router.get("")
|
|
def list_activity(limit: int=200, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
"""Get activity entries for current profile."""
|
|
pid = get_pid(x_profile_id)
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute(
|
|
"SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC, start_time DESC LIMIT %s", (pid,limit))
|
|
return [r2d(r) for r in cur.fetchall()]
|
|
|
|
|
|
@router.post("")
|
|
def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
"""Create new activity entry."""
|
|
pid = get_pid(x_profile_id)
|
|
|
|
# Phase 4: Check feature access and ENFORCE
|
|
access = check_feature_access(pid, 'activity_entries')
|
|
log_feature_usage(pid, 'activity_entries', access, 'create')
|
|
|
|
if not access['allowed']:
|
|
logger.warning(
|
|
f"[FEATURE-LIMIT] User {pid} blocked: "
|
|
f"activity_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
|
)
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"Limit erreicht: Du hast das Kontingent für Aktivitätseinträge überschritten ({access['used']}/{access['limit']}). "
|
|
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
|
)
|
|
|
|
eid = str(uuid.uuid4())
|
|
d = e.model_dump()
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute("""INSERT INTO activity_log
|
|
(id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
|
|
hr_avg,hr_max,distance_km,rpe,source,notes,created)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
|
|
(eid,pid,d['date'],d['start_time'],d['end_time'],d['activity_type'],d['duration_min'],
|
|
d['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'],
|
|
d['rpe'],d['source'],d['notes']))
|
|
|
|
# Phase 2: Increment usage counter (always for new entries)
|
|
increment_feature_usage(pid, 'activity_entries')
|
|
|
|
return {"id":eid,"date":e.date}
|
|
|
|
|
|
@router.put("/{eid}")
|
|
def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
"""Update existing activity entry."""
|
|
pid = get_pid(x_profile_id)
|
|
with get_db() as conn:
|
|
d = e.model_dump()
|
|
cur = get_cursor(conn)
|
|
cur.execute(f"UPDATE activity_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
|
|
list(d.values())+[eid,pid])
|
|
return {"id":eid}
|
|
|
|
|
|
@router.delete("/{eid}")
|
|
def delete_activity(eid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
"""Delete activity entry."""
|
|
pid = get_pid(x_profile_id)
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute("DELETE FROM activity_log WHERE id=%s AND profile_id=%s", (eid,pid))
|
|
return {"ok":True}
|
|
|
|
|
|
@router.get("/stats")
|
|
def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
"""Get activity statistics (last 30 entries)."""
|
|
pid = get_pid(x_profile_id)
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute(
|
|
"SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
|
|
rows = [r2d(r) for r in cur.fetchall()]
|
|
if not rows: return {"count":0,"total_kcal":0,"total_min":0,"by_type":{}}
|
|
total_kcal=sum(float(r.get('kcal_active') or 0) for r in rows)
|
|
total_min=sum(float(r.get('duration_min') or 0) for r in rows)
|
|
by_type={}
|
|
for r in rows:
|
|
t=r['activity_type']; by_type.setdefault(t,{'count':0,'kcal':0,'min':0})
|
|
by_type[t]['count']+=1
|
|
by_type[t]['kcal']+=float(r.get('kcal_active') or 0)
|
|
by_type[t]['min']+=float(r.get('duration_min') or 0)
|
|
return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type}
|
|
|
|
|
|
def get_training_type_for_apple_health(workout_type: str):
|
|
"""
|
|
Map Apple Health workout type to training_type_id + category + subcategory.
|
|
Returns: (training_type_id, category, subcategory) or (None, None, None)
|
|
"""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
# Mapping: Apple Health Workout Type → training_type subcategory
|
|
# Supports English and German workout names
|
|
mapping = {
|
|
# English
|
|
'running': 'running',
|
|
'walking': 'walk',
|
|
'hiking': 'walk',
|
|
'cycling': 'cycling',
|
|
'swimming': 'swimming',
|
|
'traditional strength training': 'hypertrophy',
|
|
'functional strength training': 'functional',
|
|
'high intensity interval training': 'hiit',
|
|
'yoga': 'yoga',
|
|
'martial arts': 'technique',
|
|
'boxing': 'sparring',
|
|
'rowing': 'rowing',
|
|
'dance': 'dance',
|
|
'core training': 'functional',
|
|
'flexibility': 'static',
|
|
'cooldown': 'regeneration',
|
|
'meditation': 'meditation',
|
|
'mindfulness': 'mindfulness',
|
|
|
|
# German (Deutsch)
|
|
'laufen': 'running',
|
|
'gehen': 'walk',
|
|
'wandern': 'walk',
|
|
'outdoor spaziergang': 'walk',
|
|
'innenräume spaziergang': 'walk',
|
|
'spaziergang': 'walk',
|
|
'radfahren': 'cycling',
|
|
'schwimmen': 'swimming',
|
|
'traditionelles krafttraining': 'hypertrophy',
|
|
'funktionelles krafttraining': 'functional',
|
|
'hochintensives intervalltraining': 'hiit',
|
|
'yoga': 'yoga',
|
|
'kampfsport': 'technique',
|
|
'matrial arts': 'technique', # Common typo in Apple Health
|
|
'boxen': 'sparring',
|
|
'rudern': 'rowing',
|
|
'tanzen': 'dance',
|
|
'cardio dance': 'dance',
|
|
'core training': 'functional',
|
|
'flexibilität': 'static',
|
|
'abwärmen': 'regeneration',
|
|
'cooldown': 'regeneration',
|
|
'meditation': 'meditation',
|
|
'achtsamkeit': 'mindfulness',
|
|
'geist & körper': 'yoga', # Mind & Body → Yoga category
|
|
}
|
|
|
|
subcategory = mapping.get(workout_type.lower())
|
|
if not subcategory:
|
|
return (None, None, None)
|
|
|
|
# Find training_type_id by subcategory
|
|
cur.execute("""
|
|
SELECT id, category, subcategory
|
|
FROM training_types
|
|
WHERE LOWER(subcategory) = %s
|
|
LIMIT 1
|
|
""", (subcategory,))
|
|
row = cur.fetchone()
|
|
|
|
if row:
|
|
return (row['id'], row['category'], row['subcategory'])
|
|
|
|
return (None, None, None)
|
|
|
|
|
|
@router.get("/uncategorized")
|
|
def list_uncategorized_activities(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
"""Get activities without assigned training type, grouped by activity_type."""
|
|
pid = get_pid(x_profile_id)
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute("""
|
|
SELECT activity_type, COUNT(*) as count,
|
|
MIN(date) as first_date, MAX(date) as last_date
|
|
FROM activity_log
|
|
WHERE profile_id=%s AND training_type_id IS NULL
|
|
GROUP BY activity_type
|
|
ORDER BY count DESC
|
|
""", (pid,))
|
|
return [r2d(r) for r in cur.fetchall()]
|
|
|
|
|
|
@router.post("/bulk-categorize")
|
|
def bulk_categorize_activities(
|
|
data: dict,
|
|
x_profile_id: Optional[str]=Header(default=None),
|
|
session: dict=Depends(require_auth)
|
|
):
|
|
"""
|
|
Bulk update training type for activities.
|
|
Body: {
|
|
"activity_type": "Running",
|
|
"training_type_id": 1,
|
|
"training_category": "cardio",
|
|
"training_subcategory": "running"
|
|
}
|
|
"""
|
|
pid = get_pid(x_profile_id)
|
|
activity_type = data.get('activity_type')
|
|
training_type_id = data.get('training_type_id')
|
|
training_category = data.get('training_category')
|
|
training_subcategory = data.get('training_subcategory')
|
|
|
|
if not activity_type or not training_type_id:
|
|
raise HTTPException(400, "activity_type and training_type_id required")
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute("""
|
|
UPDATE activity_log
|
|
SET training_type_id = %s,
|
|
training_category = %s,
|
|
training_subcategory = %s
|
|
WHERE profile_id = %s
|
|
AND activity_type = %s
|
|
AND training_type_id IS NULL
|
|
""", (training_type_id, training_category, training_subcategory, pid, activity_type))
|
|
updated_count = cur.rowcount
|
|
|
|
return {"updated": updated_count, "activity_type": activity_type}
|
|
|
|
|
|
@router.post("/import-csv")
|
|
async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
"""Import Apple Health workout CSV with automatic training type mapping."""
|
|
pid = get_pid(x_profile_id)
|
|
raw = await file.read()
|
|
try: text = raw.decode('utf-8')
|
|
except: text = raw.decode('latin-1')
|
|
if text.startswith('\ufeff'): text = text[1:]
|
|
if not text.strip(): raise HTTPException(400,"Leere Datei")
|
|
reader = csv.DictReader(io.StringIO(text))
|
|
inserted = skipped = 0
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
for row in reader:
|
|
wtype = row.get('Workout Type','').strip()
|
|
start = row.get('Start','').strip()
|
|
if not wtype or not start: continue
|
|
try: date = start[:10]
|
|
except: continue
|
|
dur = row.get('Duration','').strip()
|
|
duration_min = None
|
|
if dur:
|
|
try:
|
|
p = dur.split(':')
|
|
duration_min = round(int(p[0])*60+int(p[1])+int(p[2])/60,1)
|
|
except: pass
|
|
def kj(v):
|
|
try: return round(float(v)/4.184) if v else None
|
|
except: return None
|
|
def tf(v):
|
|
try: return round(float(v),1) if v else None
|
|
except: return None
|
|
# Map Apple Health workout type to training_type_id
|
|
training_type_id, training_category, training_subcategory = get_training_type_for_apple_health(wtype)
|
|
|
|
try:
|
|
# Check if entry already exists (duplicate detection by date + start_time)
|
|
cur.execute("""
|
|
SELECT id FROM activity_log
|
|
WHERE profile_id = %s AND date = %s AND start_time = %s
|
|
""", (pid, date, start))
|
|
existing = cur.fetchone()
|
|
|
|
if existing:
|
|
# Update existing entry (e.g., to add training type mapping)
|
|
cur.execute("""
|
|
UPDATE activity_log
|
|
SET end_time = %s,
|
|
activity_type = %s,
|
|
duration_min = %s,
|
|
kcal_active = %s,
|
|
kcal_resting = %s,
|
|
hr_avg = %s,
|
|
hr_max = %s,
|
|
distance_km = %s,
|
|
training_type_id = %s,
|
|
training_category = %s,
|
|
training_subcategory = %s
|
|
WHERE id = %s
|
|
""", (
|
|
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)','')),
|
|
training_type_id, training_category, training_subcategory,
|
|
existing['id']
|
|
))
|
|
skipped += 1 # Count as skipped (not newly inserted)
|
|
else:
|
|
# Insert new entry
|
|
cur.execute("""INSERT INTO activity_log
|
|
(id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
|
|
hr_avg,hr_max,distance_km,source,training_type_id,training_category,training_subcategory,created)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',%s,%s,%s,CURRENT_TIMESTAMP)""",
|
|
(str(uuid.uuid4()),pid,date,start,row.get('End',''),wtype,duration_min,
|
|
kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')),
|
|
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
|
tf(row.get('Max. Herzfrequenz (count/min)','')),
|
|
tf(row.get('Distanz (km)','')),
|
|
training_type_id,training_category,training_subcategory))
|
|
inserted+=1
|
|
except Exception as e:
|
|
logger.warning(f"Import row failed: {e}")
|
|
skipped+=1
|
|
return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}
|