""" Goals Router - Core Goal CRUD & Focus Areas (Streamlined v2.0) Endpoints for managing: - Strategic focus areas (weighted multi-goal system) - Tactical goal targets (concrete values with deadlines) - Grouped goal views Part of v9h Goal System (Phase 0a). NOTE: Code split complete! Related endpoints moved to: - goal_types.py → Goal Type Definitions (Admin CRUD) - goal_progress.py → Progress tracking - training_phases.py → Training phase management - fitness_tests.py → Fitness test recording """ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from typing import Optional, List from datetime import date, timedelta from db import get_db, get_cursor, r2d from auth import require_auth from goal_utils import get_current_value_for_goal router = APIRouter(prefix="/api/goals", tags=["goals"]) def serialize_dates(obj): """Convert date/datetime objects to ISO format strings for JSON serialization.""" if obj is None: return None if isinstance(obj, dict): return {k: serialize_dates(v) for k, v in obj.items()} if isinstance(obj, list): return [serialize_dates(item) for item in obj] if isinstance(obj, (date,)): return obj.isoformat() return obj # ============================================================================ # Pydantic Models # ============================================================================ class GoalModeUpdate(BaseModel): """Update strategic goal mode (deprecated - use FocusAreasUpdate)""" goal_mode: str # weight_loss, strength, endurance, recomposition, health class FocusAreasUpdate(BaseModel): """Update focus area weights (v2.0)""" weight_loss_pct: int muscle_gain_pct: int strength_pct: int endurance_pct: int flexibility_pct: int health_pct: int class FocusContribution(BaseModel): """Focus area contribution (v2.0)""" focus_area_id: str contribution_weight: float = 100.0 # 0-100% 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 # Kept for backward compatibility target_value: float unit: str # kg, %, ml/kg/min, bpm, mmHg, cm, reps target_date: Optional[date] = None start_date: Optional[date] = None # When goal started (defaults to today, can be historical) start_value: Optional[float] = None # Auto-populated from start_date if not provided category: Optional[str] = 'other' # body, training, nutrition, recovery, health, other priority: Optional[int] = 2 # 1=high, 2=medium, 3=low name: Optional[str] = None description: Optional[str] = None focus_contributions: Optional[List[FocusContribution]] = [] # v2.0: Many-to-Many class GoalUpdate(BaseModel): """Update existing goal""" target_value: Optional[float] = None target_date: Optional[date] = None start_date: Optional[date] = None # Change start date (recalculates start_value) start_value: Optional[float] = None # Manually override start value status: Optional[str] = None # active, reached, abandoned, expired is_primary: Optional[bool] = None # Kept for backward compatibility category: Optional[str] = None # body, training, nutrition, recovery, health, other priority: Optional[int] = None # 1=high, 2=medium, 3=low name: Optional[str] = None description: Optional[str] = None focus_contributions: Optional[List[FocusContribution]] = None # v2.0: Many-to-Many # ============================================================================ # 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') # ============================================================================ # Focus Areas (v2.0): Weighted Multi-Goal System # ============================================================================ @router.get("/focus-areas") def get_focus_areas(session: dict = Depends(require_auth)): """ Get current focus area weights. Returns custom weights if set, otherwise derives from goal_mode. """ pid = session['profile_id'] with get_db() as conn: cur = get_cursor(conn) # Try to get custom focus areas (user_focus_preferences after Migration 031) try: cur.execute(""" SELECT weight_loss_pct, muscle_gain_pct, strength_pct, endurance_pct, flexibility_pct, health_pct, created_at, updated_at FROM user_focus_preferences WHERE profile_id = %s LIMIT 1 """, (pid,)) row = cur.fetchone() except Exception as e: # Migration 031 not applied yet, try old table name print(f"[WARNING] user_focus_preferences not found, trying old focus_areas: {e}") try: cur.execute(""" SELECT weight_loss_pct, muscle_gain_pct, strength_pct, endurance_pct, flexibility_pct, health_pct, created_at, updated_at FROM focus_areas WHERE profile_id = %s AND active = true LIMIT 1 """, (pid,)) row = cur.fetchone() except: row = None if row: return { "custom": True, "weight_loss_pct": row['weight_loss_pct'], "muscle_gain_pct": row['muscle_gain_pct'], "strength_pct": row['strength_pct'], "endurance_pct": row['endurance_pct'], "flexibility_pct": row['flexibility_pct'], "health_pct": row['health_pct'], "updated_at": row['updated_at'] } # Fallback: Derive from goal_mode cur.execute("SELECT goal_mode FROM profiles WHERE id = %s", (pid,)) profile = cur.fetchone() if not profile or not profile['goal_mode']: # Default balanced health return { "custom": False, "weight_loss_pct": 0, "muscle_gain_pct": 0, "strength_pct": 10, "endurance_pct": 20, "flexibility_pct": 15, "health_pct": 55, "source": "default" } # Derive from goal_mode (using same logic as migration) mode = profile['goal_mode'] mode_mappings = { 'weight_loss': { 'weight_loss_pct': 60, 'muscle_gain_pct': 0, 'strength_pct': 10, 'endurance_pct': 20, 'flexibility_pct': 5, 'health_pct': 5 }, 'strength': { 'weight_loss_pct': 0, 'muscle_gain_pct': 40, 'strength_pct': 50, 'endurance_pct': 10, 'flexibility_pct': 0, 'health_pct': 0 }, 'endurance': { 'weight_loss_pct': 0, 'muscle_gain_pct': 0, 'strength_pct': 0, 'endurance_pct': 70, 'flexibility_pct': 10, 'health_pct': 20 }, 'recomposition': { 'weight_loss_pct': 30, 'muscle_gain_pct': 30, 'strength_pct': 25, 'endurance_pct': 10, 'flexibility_pct': 5, 'health_pct': 0 }, 'health': { 'weight_loss_pct': 0, 'muscle_gain_pct': 0, 'strength_pct': 10, 'endurance_pct': 20, 'flexibility_pct': 15, 'health_pct': 55 } } mapping = mode_mappings.get(mode, mode_mappings['health']) mapping['custom'] = False mapping['source'] = f"goal_mode:{mode}" return mapping @router.put("/focus-areas") def update_focus_areas(data: FocusAreasUpdate, session: dict = Depends(require_auth)): """ Update focus area weights (upsert). Validates that sum = 100 and all values are 0-100. """ pid = session['profile_id'] # Validate sum = 100 total = ( data.weight_loss_pct + data.muscle_gain_pct + data.strength_pct + data.endurance_pct + data.flexibility_pct + data.health_pct ) if total != 100: raise HTTPException( status_code=400, detail=f"Summe muss 100% sein (aktuell: {total}%)" ) # Validate range 0-100 values = [ data.weight_loss_pct, data.muscle_gain_pct, data.strength_pct, data.endurance_pct, data.flexibility_pct, data.health_pct ] if any(v < 0 or v > 100 for v in values): raise HTTPException( status_code=400, detail="Alle Werte müssen zwischen 0 und 100 liegen" ) with get_db() as conn: cur = get_cursor(conn) # Deactivate old focus_areas cur.execute( "UPDATE focus_areas SET active = false WHERE profile_id = %s", (pid,) ) # Insert new focus_areas cur.execute(""" INSERT INTO focus_areas ( profile_id, weight_loss_pct, muscle_gain_pct, strength_pct, endurance_pct, flexibility_pct, health_pct ) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( pid, data.weight_loss_pct, data.muscle_gain_pct, data.strength_pct, data.endurance_pct, data.flexibility_pct, data.health_pct )) return { "message": "Fokus-Bereiche aktualisiert", "weight_loss_pct": data.weight_loss_pct, "muscle_gain_pct": data.muscle_gain_pct, "strength_pct": data.strength_pct, "endurance_pct": data.endurance_pct, "flexibility_pct": data.flexibility_pct, "health_pct": data.health_pct } # ============================================================================ # Tactical Layer: Concrete Goals - Core CRUD # ============================================================================ @router.get("/list") def list_goals(session: dict = Depends(require_auth)): """List all goals for current user""" pid = session['profile_id'] try: 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()] # Debug: Show first goal with dates if goals: first = goals[0] # Update current values for each goal for goal in goals: try: _update_goal_progress(conn, pid, goal) except Exception as e: print(f"[ERROR] Failed to update progress for goal {goal.get('id')}: {e}") # Continue with other goals even if one fails # Serialize date objects to ISO format strings goals = serialize_dates(goals) return goals except Exception as e: print(f"[ERROR] list_goals failed: {e}") import traceback traceback.print_exc() raise HTTPException( status_code=500, detail=f"Fehler beim Laden der Ziele: {str(e)}" ) @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) # Determine start_date (default to today if not provided) start_date = data.start_date if data.start_date else date.today() # Determine start_value if data.start_value is not None: # User explicitly provided start_value start_value = data.start_value elif start_date < date.today(): # Historical start date - try to get historical value historical_data = _get_historical_value_for_goal_type(conn, pid, data.goal_type, start_date) if historical_data is not None: # Use the actual measurement date and value start_date = historical_data['date'] start_value = historical_data['value'] print(f"[INFO] Auto-adjusted start_date to {start_date} (first measurement)") else: # No data found, fall back to current value and keep original date start_value = current_value print(f"[WARN] No historical data for {data.goal_type} on or after {start_date}, using current value") else: # Start date is today, use current value start_value = current_value # Insert goal cur.execute(""" INSERT INTO goals ( profile_id, goal_type, is_primary, target_value, current_value, start_value, unit, start_date, target_date, category, priority, name, description ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( pid, data.goal_type, data.is_primary, data.target_value, current_value, start_value, data.unit, start_date, data.target_date, data.category, data.priority, data.name, data.description )) goal_id = cur.fetchone()['id'] # v2.0: Insert focus area contributions if data.focus_contributions: for contrib in data.focus_contributions: cur.execute(""" INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight) VALUES (%s, %s, %s) ON CONFLICT (goal_id, focus_area_id) DO UPDATE SET contribution_weight = EXCLUDED.contribution_weight """, (goal_id, contrib.focus_area_id, contrib.contribution_weight)) 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") # If setting this goal as primary, unset all other primary goals if data.is_primary is True: cur.execute( "UPDATE goals SET is_primary = false WHERE profile_id = %s AND id != %s", (pid, goal_id) ) # 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.is_primary is not None: updates.append("is_primary = %s") params.append(data.is_primary) if data.category is not None: updates.append("category = %s") params.append(data.category) if data.priority is not None: updates.append("priority = %s") params.append(data.priority) 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) # Handle start_date and start_value # Determine what start_date and start_value to use final_start_date = None final_start_value = None if data.start_date is not None: # User provided a start_date requested_date = data.start_date # If start_value not explicitly provided, try to get historical value if data.start_value is None: # Get goal_type for historical lookup cur.execute("SELECT goal_type FROM goals WHERE id = %s", (goal_id,)) goal_row = cur.fetchone() if goal_row: goal_type = goal_row['goal_type'] historical_data = _get_historical_value_for_goal_type(conn, pid, goal_type, requested_date) if historical_data is not None: # Use actual measurement date and value final_start_date = historical_data['date'] final_start_value = historical_data['value'] print(f"[INFO] Auto-adjusted to first measurement: {final_start_date} = {final_start_value}") else: # No historical data found, use requested date without value final_start_date = requested_date print(f"[WARN] No historical data found for {goal_type} on or after {requested_date}") else: print(f"[ERROR] Could not find goal with id {goal_id}") final_start_date = requested_date else: # User provided both date and value final_start_date = requested_date final_start_value = data.start_value elif data.start_value is not None: # Only start_value provided (no date) final_start_value = data.start_value # Add to updates if we have values if final_start_date is not None: updates.append("start_date = %s") params.append(final_start_date) if final_start_value is not None: updates.append("start_value = %s") params.append(final_start_value) # Handle focus_contributions separately (can be updated even if no other changes) if data.focus_contributions is not None: # Delete existing contributions cur.execute( "DELETE FROM goal_focus_contributions WHERE goal_id = %s", (goal_id,) ) # Insert new contributions for contrib in data.focus_contributions: cur.execute(""" INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight) VALUES (%s, %s, %s) """, (goal_id, contrib.focus_area_id, contrib.contribution_weight)) if not updates and data.focus_contributions is None: raise HTTPException(status_code=400, detail="Keine Änderungen angegeben") if updates: updates.append("updated_at = NOW()") params.extend([goal_id, pid]) update_sql = f"UPDATE goals SET {', '.join(updates)} WHERE id = %s AND profile_id = %s" cur.execute(update_sql, tuple(params)) # Verify what was actually saved cur.execute(""" SELECT id, goal_type, start_date, start_value, target_date, target_value FROM goals WHERE id = %s """, (goal_id,)) saved_goal = cur.fetchone() 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"} @router.get("/grouped") def get_goals_grouped(session: dict = Depends(require_auth)): """ Get all goals grouped by category. Returns structure: { "body": [{"id": "...", "goal_type": "weight", "priority": 1, ...}, ...], "training": [...], "nutrition": [...], "recovery": [...], "health": [...], "other": [...] } """ pid = session['profile_id'] with get_db() as conn: cur = get_cursor(conn) # Get all active goals with type definitions cur.execute(""" SELECT g.id, g.goal_type, g.target_value, g.current_value, g.start_value, g.unit, g.start_date, g.target_date, g.reached_date, g.status, g.is_primary, g.category, g.priority, g.name, g.description, g.progress_pct, g.on_track, g.projection_date, g.created_at, g.updated_at, gt.label_de, gt.icon, gt.category as type_category, gt.source_table, gt.source_column FROM goals g LEFT JOIN goal_type_definitions gt ON g.goal_type = gt.type_key WHERE g.profile_id = %s ORDER BY g.category, g.priority ASC, g.created_at DESC """, (pid,)) goals = cur.fetchall() # v2.0: Load focus_contributions for each goal goal_ids = [g['id'] for g in goals] focus_map = {} # goal_id → [contributions] if goal_ids: try: placeholders = ','.join(['%s'] * len(goal_ids)) cur.execute(f""" SELECT gfc.goal_id, gfc.contribution_weight, fa.id as focus_area_id, fa.key, fa.name_de, fa.icon, fa.category FROM goal_focus_contributions gfc JOIN focus_area_definitions fa ON gfc.focus_area_id = fa.id WHERE gfc.goal_id IN ({placeholders}) ORDER BY gfc.contribution_weight DESC """, tuple(goal_ids)) for row in cur.fetchall(): gid = row['goal_id'] if gid not in focus_map: focus_map[gid] = [] focus_map[gid].append({ 'focus_area_id': row['focus_area_id'], 'key': row['key'], 'name_de': row['name_de'], 'icon': row['icon'], 'category': row['category'], 'contribution_weight': float(row['contribution_weight']) }) except Exception as e: # Migration 031 not yet applied - focus_contributions tables don't exist print(f"[WARNING] Could not load focus_contributions: {e}") # Continue without focus_contributions (backward compatible) # Group by category and attach focus_contributions grouped = {} for goal in goals: cat = goal['category'] or 'other' if cat not in grouped: grouped[cat] = [] goal_dict = r2d(goal) goal_dict['focus_contributions'] = focus_map.get(goal['id'], []) grouped[cat].append(goal_dict) # Serialize date objects to ISO format strings grouped = serialize_dates(grouped) return grouped # ============================================================================ # 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. DEPRECATED: This function now delegates to the universal fetcher in goal_utils.py. Phase 1.5: All goal types are now defined in goal_type_definitions table. Args: conn: Database connection profile_id: User's profile ID goal_type: Goal type key (e.g., 'weight', 'meditation_minutes') Returns: Current value or None """ # Delegate to universal fetcher (Phase 1.5) return get_current_value_for_goal(conn, profile_id, goal_type) def _get_historical_value_for_goal_type(conn, profile_id: str, goal_type: str, target_date: date) -> Optional[dict]: """ Get historical value for a goal type on or after a specific date. Finds the FIRST available measurement >= target_date. Args: conn: Database connection profile_id: User's profile ID goal_type: Goal type key (e.g., 'weight', 'body_fat') target_date: Desired start date (will find first measurement on or after this date) Returns: Dict with {'value': float, 'date': date} or None if not found """ from goal_utils import get_goal_type_config, get_cursor # Get goal type configuration config = get_goal_type_config(conn, goal_type) if not config: return None source_table = config.get('source_table') source_column = config.get('source_column') if not source_table or not source_column: return None # Query for value closest to target_date (±7 days window) cur = get_cursor(conn) try: # Special handling for different tables if source_table == 'vitals_baseline': date_col = 'date' elif source_table == 'blood_pressure_log': date_col = 'recorded_at::date' else: date_col = 'date' # Find first measurement on or after target_date query = f""" SELECT {source_column}, {date_col} as measurement_date FROM {source_table} WHERE profile_id = %s AND {date_col} >= %s ORDER BY {date_col} ASC LIMIT 1 """ params = (profile_id, target_date) cur.execute(query, params) row = cur.fetchone() if row: value = row[source_column] measurement_date = row['measurement_date'] # Convert Decimal to float result_value = float(value) if value is not None else None # Handle different date types (date vs datetime) if hasattr(measurement_date, 'date'): # It's a datetime, extract date result_date = measurement_date.date() else: # It's already a date result_date = measurement_date result = {'value': result_value, 'date': result_date} return result return None except Exception as e: print(f"[ERROR] Failed to get historical value for {goal_type} on {target_date}: {e}") 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']