""" 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 from goal_utils import get_current_value_for_goal 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 is_primary: Optional[bool] = None 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 class GoalTypeCreate(BaseModel): """Create custom goal type definition""" type_key: str label_de: str label_en: Optional[str] = None unit: str icon: Optional[str] = None category: Optional[str] = 'custom' source_table: Optional[str] = None source_column: Optional[str] = None aggregation_method: Optional[str] = 'latest' calculation_formula: Optional[str] = None description: Optional[str] = None class GoalTypeUpdate(BaseModel): """Update goal type definition""" label_de: Optional[str] = None label_en: Optional[str] = None unit: Optional[str] = None icon: Optional[str] = None category: Optional[str] = None source_table: Optional[str] = None source_column: Optional[str] = None aggregation_method: Optional[str] = None calculation_formula: Optional[str] = None description: Optional[str] = None is_active: Optional[bool] = 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") # 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.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. 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 _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 # ============================================================================ # Goal Type Definitions (Phase 1.5 - Flexible Goal System) # ============================================================================ @router.get("/goal-types") def list_goal_type_definitions(session: dict = Depends(require_auth)): """ Get all active goal type definitions. Public endpoint - returns all available goal types for dropdown. """ with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT id, type_key, label_de, label_en, unit, icon, category, source_table, source_column, aggregation_method, calculation_formula, description, is_system, created_at, updated_at FROM goal_type_definitions WHERE is_active = true ORDER BY CASE WHEN is_system = true THEN 0 ELSE 1 END, label_de """) return [r2d(row) for row in cur.fetchall()] @router.post("/goal-types") def create_goal_type_definition( data: GoalTypeCreate, session: dict = Depends(require_auth) ): """ Create custom goal type definition. Admin-only endpoint for creating new goal types. Users with admin role can define custom metrics. """ pid = session['profile_id'] # Check admin role with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT role FROM profiles WHERE id = %s", (pid,)) profile = cur.fetchone() if not profile or profile['role'] != 'admin': raise HTTPException( status_code=403, detail="Admin-Zugriff erforderlich" ) # Validate type_key is unique cur.execute( "SELECT id FROM goal_type_definitions WHERE type_key = %s", (data.type_key,) ) if cur.fetchone(): raise HTTPException( status_code=400, detail=f"Goal Type '{data.type_key}' existiert bereits" ) # Insert new goal type cur.execute(""" INSERT INTO goal_type_definitions ( type_key, label_de, label_en, unit, icon, category, source_table, source_column, aggregation_method, calculation_formula, description, is_active, is_system, created_by, updated_by ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( data.type_key, data.label_de, data.label_en, data.unit, data.icon, data.category, data.source_table, data.source_column, data.aggregation_method, data.calculation_formula, data.description, True, False, # is_active=True, is_system=False pid, pid )) goal_type_id = cur.fetchone()['id'] return { "id": goal_type_id, "message": f"Goal Type '{data.label_de}' erstellt" } @router.put("/goal-types/{goal_type_id}") def update_goal_type_definition( goal_type_id: str, data: GoalTypeUpdate, session: dict = Depends(require_auth) ): """ Update goal type definition. Admin-only. System goal types can be updated but not deleted. """ pid = session['profile_id'] with get_db() as conn: cur = get_cursor(conn) # Check admin role cur.execute("SELECT role FROM profiles WHERE id = %s", (pid,)) profile = cur.fetchone() if not profile or profile['role'] != 'admin': raise HTTPException( status_code=403, detail="Admin-Zugriff erforderlich" ) # Check goal type exists cur.execute( "SELECT id FROM goal_type_definitions WHERE id = %s", (goal_type_id,) ) if not cur.fetchone(): raise HTTPException(status_code=404, detail="Goal Type nicht gefunden") # Build update query updates = [] params = [] if data.label_de is not None: updates.append("label_de = %s") params.append(data.label_de) if data.label_en is not None: updates.append("label_en = %s") params.append(data.label_en) if data.unit is not None: updates.append("unit = %s") params.append(data.unit) if data.icon is not None: updates.append("icon = %s") params.append(data.icon) if data.category is not None: updates.append("category = %s") params.append(data.category) if data.source_table is not None: updates.append("source_table = %s") params.append(data.source_table) if data.source_column is not None: updates.append("source_column = %s") params.append(data.source_column) if data.aggregation_method is not None: updates.append("aggregation_method = %s") params.append(data.aggregation_method) if data.calculation_formula is not None: updates.append("calculation_formula = %s") params.append(data.calculation_formula) if data.description is not None: updates.append("description = %s") params.append(data.description) if data.is_active is not None: updates.append("is_active = %s") params.append(data.is_active) if not updates: raise HTTPException(status_code=400, detail="Keine Änderungen angegeben") updates.append("updated_at = NOW()") updates.append("updated_by = %s") params.append(pid) params.append(goal_type_id) cur.execute( f"UPDATE goal_type_definitions SET {', '.join(updates)} WHERE id = %s", tuple(params) ) return {"message": "Goal Type aktualisiert"} @router.delete("/goal-types/{goal_type_id}") def delete_goal_type_definition( goal_type_id: str, session: dict = Depends(require_auth) ): """ Delete (deactivate) goal type definition. Admin-only. System goal types cannot be deleted, only deactivated. Custom goal types can be fully deleted if no goals reference them. """ pid = session['profile_id'] with get_db() as conn: cur = get_cursor(conn) # Check admin role cur.execute("SELECT role FROM profiles WHERE id = %s", (pid,)) profile = cur.fetchone() if not profile or profile['role'] != 'admin': raise HTTPException( status_code=403, detail="Admin-Zugriff erforderlich" ) # Get goal type info cur.execute( "SELECT id, type_key, is_system FROM goal_type_definitions WHERE id = %s", (goal_type_id,) ) goal_type = cur.fetchone() if not goal_type: raise HTTPException(status_code=404, detail="Goal Type nicht gefunden") # Check if any goals use this type cur.execute( "SELECT COUNT(*) as count FROM goals WHERE goal_type = %s", (goal_type['type_key'],) ) count = cur.fetchone()['count'] if count > 0: # Deactivate instead of delete cur.execute( "UPDATE goal_type_definitions SET is_active = false WHERE id = %s", (goal_type_id,) ) return { "message": f"Goal Type deaktiviert ({count} Ziele nutzen diesen Typ)" } else: if goal_type['is_system']: # System types: only deactivate cur.execute( "UPDATE goal_type_definitions SET is_active = false WHERE id = %s", (goal_type_id,) ) return {"message": "System Goal Type deaktiviert"} else: # Custom types: delete cur.execute( "DELETE FROM goal_type_definitions WHERE id = %s", (goal_type_id,) ) return {"message": "Goal Type gelöscht"}