mitai-jinkendo/backend/routers/goals.py
Lars 906a3b7cdd
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
fix: Migration 022 - remove invalid schema_migrations tracking
The migration system tracks migrations via filename automatically.
Removed manual DO block that used wrong column name (version vs filename).

Also removed unused json import from goals.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 16:26:48 +01:00

473 lines
16 KiB
Python

"""
Goals Router - Goal System (Strategic + Tactical)
Endpoints for managing:
- Strategic goal modes (weight_loss, strength, etc.)
- Tactical goal targets (concrete values with deadlines)
- Training phase detection
- Fitness tests
Part of v9e Goal System implementation.
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional, List
from datetime import date, datetime, timedelta
from decimal import Decimal
from db import get_db, get_cursor, r2d
from auth import require_auth
router = APIRouter(prefix="/api/goals", tags=["goals"])
# ============================================================================
# Pydantic Models
# ============================================================================
class GoalModeUpdate(BaseModel):
"""Update strategic goal mode"""
goal_mode: str # weight_loss, strength, endurance, recomposition, health
class GoalCreate(BaseModel):
"""Create or update a concrete goal"""
goal_type: str # weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr
is_primary: bool = False
target_value: float
unit: str # kg, %, ml/kg/min, bpm, mmHg, cm, reps
target_date: Optional[date] = None
name: Optional[str] = None
description: Optional[str] = None
class GoalUpdate(BaseModel):
"""Update existing goal"""
target_value: Optional[float] = None
target_date: Optional[date] = None
status: Optional[str] = None # active, reached, abandoned, expired
name: Optional[str] = None
description: Optional[str] = None
class TrainingPhaseCreate(BaseModel):
"""Create training phase (manual or auto-detected)"""
phase_type: str # calorie_deficit, calorie_surplus, deload, maintenance, periodization
start_date: date
end_date: Optional[date] = None
notes: Optional[str] = None
class FitnessTestCreate(BaseModel):
"""Record fitness test result"""
test_type: str
result_value: float
result_unit: str
test_date: date
test_conditions: Optional[str] = None
# ============================================================================
# Strategic Layer: Goal Modes
# ============================================================================
@router.get("/mode")
def get_goal_mode(session: dict = Depends(require_auth)):
"""Get user's current strategic goal mode"""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT goal_mode FROM profiles WHERE id = %s",
(pid,)
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
return {
"goal_mode": row['goal_mode'] or 'health',
"description": _get_goal_mode_description(row['goal_mode'] or 'health')
}
@router.put("/mode")
def update_goal_mode(data: GoalModeUpdate, session: dict = Depends(require_auth)):
"""Update user's strategic goal mode"""
pid = session['profile_id']
# Validate goal mode
valid_modes = ['weight_loss', 'strength', 'endurance', 'recomposition', 'health']
if data.goal_mode not in valid_modes:
raise HTTPException(
status_code=400,
detail=f"Ungültiger Goal Mode. Erlaubt: {', '.join(valid_modes)}"
)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"UPDATE profiles SET goal_mode = %s WHERE id = %s",
(data.goal_mode, pid)
)
return {
"goal_mode": data.goal_mode,
"description": _get_goal_mode_description(data.goal_mode)
}
def _get_goal_mode_description(mode: str) -> str:
"""Get description for goal mode"""
descriptions = {
'weight_loss': 'Gewichtsreduktion (Kaloriendefizit, Fettabbau)',
'strength': 'Kraftaufbau (Muskelwachstum, progressive Belastung)',
'endurance': 'Ausdauer (VO2Max, aerobe Kapazität)',
'recomposition': 'Körperkomposition (gleichzeitig Fett ab- und Muskeln aufbauen)',
'health': 'Allgemeine Gesundheit (ausgewogen, präventiv)'
}
return descriptions.get(mode, 'Unbekannt')
# ============================================================================
# Tactical Layer: Concrete Goals
# ============================================================================
@router.get("/list")
def list_goals(session: dict = Depends(require_auth)):
"""List all goals for current user"""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT id, goal_type, is_primary, status,
target_value, current_value, start_value, unit,
start_date, target_date, reached_date,
name, description,
progress_pct, projection_date, on_track,
created_at, updated_at
FROM goals
WHERE profile_id = %s
ORDER BY is_primary DESC, created_at DESC
""", (pid,))
goals = [r2d(row) for row in cur.fetchall()]
# Update current values for each goal
for goal in goals:
_update_goal_progress(conn, pid, goal)
return goals
@router.post("/create")
def create_goal(data: GoalCreate, session: dict = Depends(require_auth)):
"""Create new goal"""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
# If this is set as primary, unset other primary goals
if data.is_primary:
cur.execute(
"UPDATE goals SET is_primary = false WHERE profile_id = %s",
(pid,)
)
# Get current value for this goal type
current_value = _get_current_value_for_goal_type(conn, pid, data.goal_type)
# Insert goal
cur.execute("""
INSERT INTO goals (
profile_id, goal_type, is_primary,
target_value, current_value, start_value, unit,
target_date, name, description
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
pid, data.goal_type, data.is_primary,
data.target_value, current_value, current_value, data.unit,
data.target_date, data.name, data.description
))
goal_id = cur.fetchone()['id']
return {"id": goal_id, "message": "Ziel erstellt"}
@router.put("/{goal_id}")
def update_goal(goal_id: str, data: GoalUpdate, session: dict = Depends(require_auth)):
"""Update existing goal"""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
# Verify ownership
cur.execute(
"SELECT id FROM goals WHERE id = %s AND profile_id = %s",
(goal_id, pid)
)
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Ziel nicht gefunden")
# Build update query dynamically
updates = []
params = []
if data.target_value is not None:
updates.append("target_value = %s")
params.append(data.target_value)
if data.target_date is not None:
updates.append("target_date = %s")
params.append(data.target_date)
if data.status is not None:
updates.append("status = %s")
params.append(data.status)
if data.status == 'reached':
updates.append("reached_date = CURRENT_DATE")
if data.name is not None:
updates.append("name = %s")
params.append(data.name)
if data.description is not None:
updates.append("description = %s")
params.append(data.description)
if not updates:
raise HTTPException(status_code=400, detail="Keine Änderungen angegeben")
updates.append("updated_at = NOW()")
params.extend([goal_id, pid])
cur.execute(
f"UPDATE goals SET {', '.join(updates)} WHERE id = %s AND profile_id = %s",
tuple(params)
)
return {"message": "Ziel aktualisiert"}
@router.delete("/{goal_id}")
def delete_goal(goal_id: str, session: dict = Depends(require_auth)):
"""Delete goal"""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"DELETE FROM goals WHERE id = %s AND profile_id = %s",
(goal_id, pid)
)
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="Ziel nicht gefunden")
return {"message": "Ziel gelöscht"}
# ============================================================================
# Training Phases
# ============================================================================
@router.get("/phases")
def list_training_phases(session: dict = Depends(require_auth)):
"""List training phases"""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT id, phase_type, detected_automatically, confidence_score,
status, start_date, end_date, duration_days,
detection_params, notes, created_at
FROM training_phases
WHERE profile_id = %s
ORDER BY start_date DESC
""", (pid,))
return [r2d(row) for row in cur.fetchall()]
@router.post("/phases")
def create_training_phase(data: TrainingPhaseCreate, session: dict = Depends(require_auth)):
"""Create training phase (manual)"""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
duration = None
if data.end_date:
duration = (data.end_date - data.start_date).days
cur.execute("""
INSERT INTO training_phases (
profile_id, phase_type, detected_automatically,
status, start_date, end_date, duration_days, notes
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
pid, data.phase_type, False,
'active', data.start_date, data.end_date, duration, data.notes
))
phase_id = cur.fetchone()['id']
return {"id": phase_id, "message": "Trainingsphase erstellt"}
@router.put("/phases/{phase_id}/status")
def update_phase_status(
phase_id: str,
status: str,
session: dict = Depends(require_auth)
):
"""Update training phase status (accept/reject auto-detected phases)"""
pid = session['profile_id']
valid_statuses = ['suggested', 'accepted', 'active', 'completed', 'rejected']
if status not in valid_statuses:
raise HTTPException(
status_code=400,
detail=f"Ungültiger Status. Erlaubt: {', '.join(valid_statuses)}"
)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"UPDATE training_phases SET status = %s WHERE id = %s AND profile_id = %s",
(status, phase_id, pid)
)
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="Trainingsphase nicht gefunden")
return {"message": "Status aktualisiert"}
# ============================================================================
# Fitness Tests
# ============================================================================
@router.get("/tests")
def list_fitness_tests(session: dict = Depends(require_auth)):
"""List all fitness tests"""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT id, test_type, result_value, result_unit,
test_date, test_conditions, norm_category, created_at
FROM fitness_tests
WHERE profile_id = %s
ORDER BY test_date DESC
""", (pid,))
return [r2d(row) for row in cur.fetchall()]
@router.post("/tests")
def create_fitness_test(data: FitnessTestCreate, session: dict = Depends(require_auth)):
"""Record fitness test result"""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
# Calculate norm category (simplified for now)
norm_category = _calculate_norm_category(
data.test_type,
data.result_value,
data.result_unit
)
cur.execute("""
INSERT INTO fitness_tests (
profile_id, test_type, result_value, result_unit,
test_date, test_conditions, norm_category
) VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
pid, data.test_type, data.result_value, data.result_unit,
data.test_date, data.test_conditions, norm_category
))
test_id = cur.fetchone()['id']
return {"id": test_id, "norm_category": norm_category}
# ============================================================================
# Helper Functions
# ============================================================================
def _get_current_value_for_goal_type(conn, profile_id: str, goal_type: str) -> Optional[float]:
"""Get current value for a goal type from latest data"""
cur = get_cursor(conn)
if goal_type == 'weight':
cur.execute("""
SELECT weight FROM weight_log
WHERE profile_id = %s
ORDER BY date DESC LIMIT 1
""", (profile_id,))
row = cur.fetchone()
return float(row['weight']) if row else None
elif goal_type == 'body_fat':
cur.execute("""
SELECT body_fat_pct FROM caliper_log
WHERE profile_id = %s
ORDER BY date DESC LIMIT 1
""", (profile_id,))
row = cur.fetchone()
return float(row['body_fat_pct']) if row else None
elif goal_type == 'vo2max':
cur.execute("""
SELECT vo2max FROM vitals_baseline
WHERE profile_id = %s AND vo2max IS NOT NULL
ORDER BY date DESC LIMIT 1
""", (profile_id,))
row = cur.fetchone()
return float(row['vo2max']) if row else None
elif goal_type == 'rhr':
cur.execute("""
SELECT resting_hr FROM vitals_baseline
WHERE profile_id = %s AND resting_hr IS NOT NULL
ORDER BY date DESC LIMIT 1
""", (profile_id,))
row = cur.fetchone()
return float(row['resting_hr']) if row else None
return None
def _update_goal_progress(conn, profile_id: str, goal: dict):
"""Update goal progress (modifies goal dict in-place)"""
# Get current value
current = _get_current_value_for_goal_type(conn, profile_id, goal['goal_type'])
if current is not None and goal['start_value'] is not None and goal['target_value'] is not None:
goal['current_value'] = current
# Calculate progress percentage
total_delta = float(goal['target_value']) - float(goal['start_value'])
current_delta = current - float(goal['start_value'])
if total_delta != 0:
progress_pct = (current_delta / total_delta) * 100
goal['progress_pct'] = round(progress_pct, 2)
# Simple linear projection
if goal['start_date'] and current_delta != 0:
days_elapsed = (date.today() - goal['start_date']).days
if days_elapsed > 0:
days_per_unit = days_elapsed / current_delta
remaining_units = float(goal['target_value']) - current
remaining_days = int(days_per_unit * remaining_units)
goal['projection_date'] = date.today() + timedelta(days=remaining_days)
# Check if on track
if goal['target_date'] and goal['projection_date']:
goal['on_track'] = goal['projection_date'] <= goal['target_date']
def _calculate_norm_category(test_type: str, value: float, unit: str) -> Optional[str]:
"""
Calculate norm category for fitness test
(Simplified - would need age/gender-specific norms)
"""
# Placeholder - should use proper norm tables
return None