mitai-jinkendo/backend/routers/activity.py
Lars 96b0acacd2
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
feat: automatic training type mapping for Apple Health import and bulk categorization
- Add get_training_type_for_apple_health() mapping function (23 workout types)
- CSV import now automatically assigns training_type_id/category/subcategory
- New endpoint: GET /activity/uncategorized (grouped by activity_type)
- New endpoint: POST /activity/bulk-categorize (bulk update training types)
- New component: BulkCategorize with two-level dropdown selection
- ActivityPage: new "Kategorisieren" tab for existing activities
- Update CLAUDE.md: v9d Phase 1b progress

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 15:08:18 +01:00

269 lines
11 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
mapping = {
'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': 'other',
'core training': 'functional',
'flexibility': 'static',
'cooldown': 'regeneration',
}
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:
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: skipped+=1
return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}