From 12d516c8812cdc9d5a784c0b7baabad531304e74 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 28 Mar 2026 06:31:31 +0100 Subject: [PATCH] refactor: split goals.py into 5 modular routers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code Splitting Results: - goals.py: 1339 → 655 lines (-684 lines, -51%) - Created 4 new routers: * goal_types.py (426 lines) - Goal Type Definitions CRUD * goal_progress.py (155 lines) - Progress tracking * training_phases.py (107 lines) - Training phases * fitness_tests.py (94 lines) - Fitness tests Benefits: ✅ Improved maintainability (smaller, focused files) ✅ Better context window efficiency for AI tools ✅ Clearer separation of concerns ✅ Easier testing and debugging All routers registered in main.py. Backward compatible - no API changes. --- backend/main.py | 7 +- backend/routers/fitness_tests.py | 94 ++++ backend/routers/goal_progress.py | 155 +++++++ backend/routers/goal_types.py | 426 +++++++++++++++++ backend/routers/goals.py | 710 +---------------------------- backend/routers/training_phases.py | 107 +++++ 6 files changed, 801 insertions(+), 698 deletions(-) create mode 100644 backend/routers/fitness_tests.py create mode 100644 backend/routers/goal_progress.py create mode 100644 backend/routers/goal_types.py create mode 100644 backend/routers/training_phases.py diff --git a/backend/main.py b/backend/main.py index 3999f85..a457c7a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -24,6 +24,7 @@ from routers import admin_activity_mappings, sleep, rest_days from routers import vitals_baseline, blood_pressure # v9d Phase 2d Refactored from routers import evaluation # v9d/v9e Training Type Profiles (#15) from routers import goals, focus_areas # v9e/v9g Goal System v2.0 (Dynamic Focus Areas) +from routers import goal_types, goal_progress, training_phases, fitness_tests # v9h Goal System (Split routers) # ── App Configuration ───────────────────────────────────────────────────────── DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) @@ -98,7 +99,11 @@ app.include_router(rest_days.router) # /api/rest-days/* (v9d Phase 2a app.include_router(vitals_baseline.router) # /api/vitals/baseline/* (v9d Phase 2d Refactored) app.include_router(blood_pressure.router) # /api/blood-pressure/* (v9d Phase 2d Refactored) app.include_router(evaluation.router) # /api/evaluation/* (v9d/v9e Training Profiles #15) -app.include_router(goals.router) # /api/goals/* (v9e Goal System Strategic + Tactical) +app.include_router(goals.router) # /api/goals/* (v9h Goal System Core CRUD + Focus Areas) +app.include_router(goal_types.router) # /api/goals/goal-types/* (v9h Goal Type Definitions) +app.include_router(goal_progress.router) # /api/goals/{goal_id}/progress/* (v9h Progress Tracking) +app.include_router(training_phases.router) # /api/goals/phases/* (v9h Training Phases) +app.include_router(fitness_tests.router) # /api/goals/tests/* (v9h Fitness Tests) app.include_router(focus_areas.router) # /api/focus-areas/* (v9g Focus Area System v2.0 - Dynamic) # ── Health Check ────────────────────────────────────────────────────────────── diff --git a/backend/routers/fitness_tests.py b/backend/routers/fitness_tests.py new file mode 100644 index 0000000..a48840c --- /dev/null +++ b/backend/routers/fitness_tests.py @@ -0,0 +1,94 @@ +""" +Fitness Tests Router - Fitness Test Recording & Norm Tracking + +Endpoints for managing fitness tests: +- List fitness tests +- Record fitness test results +- Calculate norm categories + +Part of v9h Goal System. +""" +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional +from datetime import date + +from db import get_db, get_cursor, r2d +from auth import require_auth + +router = APIRouter(prefix="/api/goals", tags=["fitness-tests"]) + +# ============================================================================ +# Pydantic Models +# ============================================================================ + +class FitnessTestCreate(BaseModel): + """Record fitness test result""" + test_type: str + result_value: float + result_unit: str + test_date: date + test_conditions: Optional[str] = None + +# ============================================================================ +# Endpoints +# ============================================================================ + +@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 _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 diff --git a/backend/routers/goal_progress.py b/backend/routers/goal_progress.py new file mode 100644 index 0000000..40b648f --- /dev/null +++ b/backend/routers/goal_progress.py @@ -0,0 +1,155 @@ +""" +Goal Progress Router - Progress Tracking for Goals + +Endpoints for logging and managing goal progress: +- Get progress history +- Create manual progress entries +- Delete progress entries + +Part of v9h Goal System. +""" +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional +from datetime import date + +from db import get_db, get_cursor, r2d +from auth import require_auth + +router = APIRouter(prefix="/api/goals", tags=["goal-progress"]) + +# ============================================================================ +# Pydantic Models +# ============================================================================ + +class GoalProgressCreate(BaseModel): + """Log progress for a goal""" + date: date + value: float + note: Optional[str] = None + +class GoalProgressUpdate(BaseModel): + """Update progress entry""" + value: Optional[float] = None + note: Optional[str] = None + +# ============================================================================ +# Endpoints +# ============================================================================ + +@router.get("/{goal_id}/progress") +def get_goal_progress(goal_id: str, session: dict = Depends(require_auth)): + """Get progress history for a 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") + + # Get progress entries + cur.execute(""" + SELECT id, date, value, note, source, created_at + FROM goal_progress_log + WHERE goal_id = %s + ORDER BY date DESC + """, (goal_id,)) + + entries = cur.fetchall() + return [r2d(e) for e in entries] + +@router.post("/{goal_id}/progress") +def create_goal_progress(goal_id: str, data: GoalProgressCreate, session: dict = Depends(require_auth)): + """Log new progress for a goal""" + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Verify ownership and check if manual entry is allowed + cur.execute(""" + SELECT g.id, g.unit, gt.source_table + FROM goals g + LEFT JOIN goal_type_definitions gt ON g.goal_type = gt.type_key + WHERE g.id = %s AND g.profile_id = %s + """, (goal_id, pid)) + goal = cur.fetchone() + if not goal: + raise HTTPException(status_code=404, detail="Ziel nicht gefunden") + + # Prevent manual entries for goals with automatic data sources + if goal['source_table']: + raise HTTPException( + status_code=400, + detail=f"Manuelle Einträge nicht erlaubt für automatisch erfasste Ziele. " + f"Bitte nutze die entsprechende Erfassungsseite (z.B. Gewicht, Aktivität)." + ) + + # Insert progress entry + try: + cur.execute(""" + INSERT INTO goal_progress_log (goal_id, profile_id, date, value, note, source) + VALUES (%s, %s, %s, %s, %s, 'manual') + RETURNING id + """, (goal_id, pid, data.date, data.value, data.note)) + + progress_id = cur.fetchone()['id'] + + # Trigger will auto-update goals.current_value + return { + "id": progress_id, + "message": f"Fortschritt erfasst: {data.value} {goal['unit']}" + } + + except Exception as e: + if "unique_progress_per_day" in str(e): + raise HTTPException( + status_code=400, + detail=f"Für {data.date} existiert bereits ein Eintrag. Bitte bearbeite den existierenden Eintrag." + ) + raise HTTPException(status_code=500, detail=f"Fehler beim Speichern: {str(e)}") + +@router.delete("/{goal_id}/progress/{progress_id}") +def delete_goal_progress(goal_id: str, progress_id: str, session: dict = Depends(require_auth)): + """Delete progress entry""" + 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") + + # Delete progress entry + cur.execute( + "DELETE FROM goal_progress_log WHERE id = %s AND goal_id = %s AND profile_id = %s", + (progress_id, goal_id, pid) + ) + + if cur.rowcount == 0: + raise HTTPException(status_code=404, detail="Progress-Eintrag nicht gefunden") + + # After deletion, recalculate current_value from remaining entries + cur.execute(""" + UPDATE goals + SET current_value = ( + SELECT value FROM goal_progress_log + WHERE goal_id = %s + ORDER BY date DESC + LIMIT 1 + ) + WHERE id = %s + """, (goal_id, goal_id)) + + return {"message": "Progress-Eintrag gelöscht"} diff --git a/backend/routers/goal_types.py b/backend/routers/goal_types.py new file mode 100644 index 0000000..0023824 --- /dev/null +++ b/backend/routers/goal_types.py @@ -0,0 +1,426 @@ +""" +Goal Types Router - Custom Goal Type Definitions + +Endpoints for managing goal type definitions (admin-only): +- CRUD for goal type definitions +- Schema info for building custom types + +Part of v9h Goal System. +""" +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional +import traceback + +from db import get_db, get_cursor, r2d +from auth import require_auth + +router = APIRouter(prefix="/api/goals", tags=["goal-types"]) + +# ============================================================================ +# Pydantic Models +# ============================================================================ + +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 + filter_conditions: Optional[dict] = 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 + filter_conditions: Optional[dict] = None + description: Optional[str] = None + is_active: Optional[bool] = None + +# ============================================================================ +# Endpoints +# ============================================================================ + +@router.get("/schema-info") +def get_schema_info(session: dict = Depends(require_auth)): + """ + Get available tables and columns for goal type creation. + + Admin-only endpoint for building custom goal types. + Returns structure with descriptions for UX guidance. + """ + 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") + + # Define relevant tables with descriptions + # Only include tables that make sense for goal tracking + schema = { + "weight_log": { + "description": "Gewichtsverlauf", + "columns": { + "weight": {"type": "DECIMAL", "description": "Körpergewicht in kg"} + } + }, + "caliper_log": { + "description": "Caliper-Messungen (Hautfalten)", + "columns": { + "body_fat_pct": {"type": "DECIMAL", "description": "Körperfettanteil in %"}, + "sum_mm": {"type": "DECIMAL", "description": "Summe Hautfalten in mm"} + } + }, + "circumference_log": { + "description": "Umfangsmessungen", + "columns": { + "c_neck": {"type": "DECIMAL", "description": "Nackenumfang in cm"}, + "c_chest": {"type": "DECIMAL", "description": "Brustumfang in cm"}, + "c_waist": {"type": "DECIMAL", "description": "Taillenumfang in cm"}, + "c_hips": {"type": "DECIMAL", "description": "Hüftumfang in cm"}, + "c_thigh_l": {"type": "DECIMAL", "description": "Oberschenkel links in cm"}, + "c_thigh_r": {"type": "DECIMAL", "description": "Oberschenkel rechts in cm"}, + "c_calf_l": {"type": "DECIMAL", "description": "Wade links in cm"}, + "c_calf_r": {"type": "DECIMAL", "description": "Wade rechts in cm"}, + "c_bicep_l": {"type": "DECIMAL", "description": "Bizeps links in cm"}, + "c_bicep_r": {"type": "DECIMAL", "description": "Bizeps rechts in cm"} + } + }, + "activity_log": { + "description": "Trainingseinheiten", + "columns": { + "id": {"type": "UUID", "description": "ID (für Zählung von Einheiten)"}, + "duration_minutes": {"type": "INTEGER", "description": "Trainingsdauer in Minuten"}, + "perceived_exertion": {"type": "INTEGER", "description": "Belastungsempfinden (1-10)"}, + "quality_rating": {"type": "INTEGER", "description": "Qualitätsbewertung (1-10)"} + } + }, + "nutrition_log": { + "description": "Ernährungstagebuch", + "columns": { + "calories": {"type": "INTEGER", "description": "Kalorien in kcal"}, + "protein_g": {"type": "DECIMAL", "description": "Protein in g"}, + "carbs_g": {"type": "DECIMAL", "description": "Kohlenhydrate in g"}, + "fat_g": {"type": "DECIMAL", "description": "Fett in g"} + } + }, + "sleep_log": { + "description": "Schlafprotokoll", + "columns": { + "total_minutes": {"type": "INTEGER", "description": "Gesamtschlafdauer in Minuten"} + } + }, + "vitals_baseline": { + "description": "Vitalwerte (morgens)", + "columns": { + "resting_hr": {"type": "INTEGER", "description": "Ruhepuls in bpm"}, + "hrv_rmssd": {"type": "INTEGER", "description": "Herzratenvariabilität (RMSSD) in ms"}, + "vo2_max": {"type": "DECIMAL", "description": "VO2 Max in ml/kg/min"}, + "spo2": {"type": "INTEGER", "description": "Sauerstoffsättigung in %"}, + "respiratory_rate": {"type": "INTEGER", "description": "Atemfrequenz pro Minute"} + } + }, + "blood_pressure_log": { + "description": "Blutdruckmessungen", + "columns": { + "systolic": {"type": "INTEGER", "description": "Systolischer Blutdruck in mmHg"}, + "diastolic": {"type": "INTEGER", "description": "Diastolischer Blutdruck in mmHg"}, + "pulse": {"type": "INTEGER", "description": "Puls in bpm"} + } + }, + "rest_days": { + "description": "Ruhetage", + "columns": { + "id": {"type": "UUID", "description": "ID (für Zählung von Ruhetagen)"} + } + } + } + + return schema + +@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. + """ + try: + 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, filter_conditions, description, is_system, is_active, + 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 + """) + + results = [r2d(row) for row in cur.fetchall()] + print(f"[DEBUG] Loaded {len(results)} goal types") + return results + + except Exception as e: + print(f"[ERROR] list_goal_type_definitions failed: {e}") + print(traceback.format_exc()) + raise HTTPException( + status_code=500, + detail=f"Fehler beim Laden der Goal Types: {str(e)}" + ) + +@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 + import json as json_lib + filter_json = json_lib.dumps(data.filter_conditions) if data.filter_conditions else None + + cur.execute(""" + INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + calculation_formula, filter_conditions, description, is_active, is_system + ) VALUES (%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, filter_json, data.description, + True, False # is_active=True, is_system=False + )) + + 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.filter_conditions is not None: + import json as json_lib + filter_json = json_lib.dumps(data.filter_conditions) if data.filter_conditions else None + updates.append("filter_conditions = %s") + params.append(filter_json) + + 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()") + 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"} diff --git a/backend/routers/goals.py b/backend/routers/goals.py index ce9eda0..7162f6d 100644 --- a/backend/routers/goals.py +++ b/backend/routers/goals.py @@ -1,20 +1,23 @@ """ -Goals Router - Goal System (Strategic + Tactical) +Goals Router - Core Goal CRUD & Focus Areas (Streamlined v2.0) Endpoints for managing: -- Strategic goal modes (weight_loss, strength, etc.) +- Strategic focus areas (weighted multi-goal system) - Tactical goal targets (concrete values with deadlines) -- Training phase detection -- Fitness tests +- Grouped goal views -Part of v9e Goal System implementation. +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, datetime, timedelta -from decimal import Decimal -import traceback +from datetime import date, timedelta from db import get_db, get_cursor, r2d from auth import require_auth @@ -69,62 +72,6 @@ class GoalUpdate(BaseModel): description: Optional[str] = None focus_contributions: Optional[List[FocusContribution]] = None # v2.0: Many-to-Many -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 GoalProgressCreate(BaseModel): - """Log progress for a goal""" - date: date - value: float - note: Optional[str] = None - -class GoalProgressUpdate(BaseModel): - """Update progress entry""" - value: Optional[float] = None - note: 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 - filter_conditions: Optional[dict] = 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 - filter_conditions: Optional[dict] = None - description: Optional[str] = None - is_active: Optional[bool] = None - # ============================================================================ # Strategic Layer: Goal Modes # ============================================================================ @@ -372,7 +319,7 @@ def update_focus_areas(data: FocusAreasUpdate, session: dict = Depends(require_a } # ============================================================================ -# Tactical Layer: Concrete Goals +# Tactical Layer: Concrete Goals - Core CRUD # ============================================================================ @router.get("/list") @@ -572,131 +519,10 @@ def delete_goal(goal_id: str, session: dict = Depends(require_auth)): return {"message": "Ziel gelöscht"} -# ============================================================================ -# Goal Progress Endpoints -# ============================================================================ - -@router.get("/{goal_id}/progress") -def get_goal_progress(goal_id: str, session: dict = Depends(require_auth)): - """Get progress history for a 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") - - # Get progress entries - cur.execute(""" - SELECT id, date, value, note, source, created_at - FROM goal_progress_log - WHERE goal_id = %s - ORDER BY date DESC - """, (goal_id,)) - - entries = cur.fetchall() - return [r2d(e) for e in entries] - -@router.post("/{goal_id}/progress") -def create_goal_progress(goal_id: str, data: GoalProgressCreate, session: dict = Depends(require_auth)): - """Log new progress for a goal""" - pid = session['profile_id'] - - with get_db() as conn: - cur = get_cursor(conn) - - # Verify ownership and check if manual entry is allowed - cur.execute(""" - SELECT g.id, g.unit, gt.source_table - FROM goals g - LEFT JOIN goal_type_definitions gt ON g.goal_type = gt.type_key - WHERE g.id = %s AND g.profile_id = %s - """, (goal_id, pid)) - goal = cur.fetchone() - if not goal: - raise HTTPException(status_code=404, detail="Ziel nicht gefunden") - - # Prevent manual entries for goals with automatic data sources - if goal['source_table']: - raise HTTPException( - status_code=400, - detail=f"Manuelle Einträge nicht erlaubt für automatisch erfasste Ziele. " - f"Bitte nutze die entsprechende Erfassungsseite (z.B. Gewicht, Aktivität)." - ) - - # Insert progress entry - try: - cur.execute(""" - INSERT INTO goal_progress_log (goal_id, profile_id, date, value, note, source) - VALUES (%s, %s, %s, %s, %s, 'manual') - RETURNING id - """, (goal_id, pid, data.date, data.value, data.note)) - - progress_id = cur.fetchone()['id'] - - # Trigger will auto-update goals.current_value - return { - "id": progress_id, - "message": f"Fortschritt erfasst: {data.value} {goal['unit']}" - } - - except Exception as e: - if "unique_progress_per_day" in str(e): - raise HTTPException( - status_code=400, - detail=f"Für {data.date} existiert bereits ein Eintrag. Bitte bearbeite den existierenden Eintrag." - ) - raise HTTPException(status_code=500, detail=f"Fehler beim Speichern: {str(e)}") - -@router.delete("/{goal_id}/progress/{progress_id}") -def delete_goal_progress(goal_id: str, progress_id: str, session: dict = Depends(require_auth)): - """Delete progress entry""" - 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") - - # Delete progress entry - cur.execute( - "DELETE FROM goal_progress_log WHERE id = %s AND goal_id = %s AND profile_id = %s", - (progress_id, goal_id, pid) - ) - - if cur.rowcount == 0: - raise HTTPException(status_code=404, detail="Progress-Eintrag nicht gefunden") - - # After deletion, recalculate current_value from remaining entries - cur.execute(""" - UPDATE goals - SET current_value = ( - SELECT value FROM goal_progress_log - WHERE goal_id = %s - ORDER BY date DESC - LIMIT 1 - ) - WHERE id = %s - """, (goal_id, goal_id)) - - return {"message": "Progress-Eintrag gelöscht"} - @router.get("/grouped") def get_goals_grouped(session: dict = Depends(require_auth)): """ - Get goals grouped by category, sorted by priority. + Get all goals grouped by category. Returns structure: { @@ -777,134 +603,6 @@ def get_goals_grouped(session: dict = Depends(require_auth)): return grouped -# ============================================================================ -# 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 # ============================================================================ @@ -955,385 +653,3 @@ def _update_goal_progress(conn, profile_id: str, goal: dict): # 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("/schema-info") -def get_schema_info(session: dict = Depends(require_auth)): - """ - Get available tables and columns for goal type creation. - - Admin-only endpoint for building custom goal types. - Returns structure with descriptions for UX guidance. - """ - 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") - - # Define relevant tables with descriptions - # Only include tables that make sense for goal tracking - schema = { - "weight_log": { - "description": "Gewichtsverlauf", - "columns": { - "weight": {"type": "DECIMAL", "description": "Körpergewicht in kg"} - } - }, - "caliper_log": { - "description": "Caliper-Messungen (Hautfalten)", - "columns": { - "body_fat_pct": {"type": "DECIMAL", "description": "Körperfettanteil in %"}, - "sum_mm": {"type": "DECIMAL", "description": "Summe Hautfalten in mm"} - } - }, - "circumference_log": { - "description": "Umfangsmessungen", - "columns": { - "c_neck": {"type": "DECIMAL", "description": "Nackenumfang in cm"}, - "c_chest": {"type": "DECIMAL", "description": "Brustumfang in cm"}, - "c_waist": {"type": "DECIMAL", "description": "Taillenumfang in cm"}, - "c_hips": {"type": "DECIMAL", "description": "Hüftumfang in cm"}, - "c_thigh_l": {"type": "DECIMAL", "description": "Oberschenkel links in cm"}, - "c_thigh_r": {"type": "DECIMAL", "description": "Oberschenkel rechts in cm"}, - "c_calf_l": {"type": "DECIMAL", "description": "Wade links in cm"}, - "c_calf_r": {"type": "DECIMAL", "description": "Wade rechts in cm"}, - "c_bicep_l": {"type": "DECIMAL", "description": "Bizeps links in cm"}, - "c_bicep_r": {"type": "DECIMAL", "description": "Bizeps rechts in cm"} - } - }, - "activity_log": { - "description": "Trainingseinheiten", - "columns": { - "id": {"type": "UUID", "description": "ID (für Zählung von Einheiten)"}, - "duration_minutes": {"type": "INTEGER", "description": "Trainingsdauer in Minuten"}, - "perceived_exertion": {"type": "INTEGER", "description": "Belastungsempfinden (1-10)"}, - "quality_rating": {"type": "INTEGER", "description": "Qualitätsbewertung (1-10)"} - } - }, - "nutrition_log": { - "description": "Ernährungstagebuch", - "columns": { - "calories": {"type": "INTEGER", "description": "Kalorien in kcal"}, - "protein_g": {"type": "DECIMAL", "description": "Protein in g"}, - "carbs_g": {"type": "DECIMAL", "description": "Kohlenhydrate in g"}, - "fat_g": {"type": "DECIMAL", "description": "Fett in g"} - } - }, - "sleep_log": { - "description": "Schlafprotokoll", - "columns": { - "total_minutes": {"type": "INTEGER", "description": "Gesamtschlafdauer in Minuten"} - } - }, - "vitals_baseline": { - "description": "Vitalwerte (morgens)", - "columns": { - "resting_hr": {"type": "INTEGER", "description": "Ruhepuls in bpm"}, - "hrv_rmssd": {"type": "INTEGER", "description": "Herzratenvariabilität (RMSSD) in ms"}, - "vo2_max": {"type": "DECIMAL", "description": "VO2 Max in ml/kg/min"}, - "spo2": {"type": "INTEGER", "description": "Sauerstoffsättigung in %"}, - "respiratory_rate": {"type": "INTEGER", "description": "Atemfrequenz pro Minute"} - } - }, - "blood_pressure_log": { - "description": "Blutdruckmessungen", - "columns": { - "systolic": {"type": "INTEGER", "description": "Systolischer Blutdruck in mmHg"}, - "diastolic": {"type": "INTEGER", "description": "Diastolischer Blutdruck in mmHg"}, - "pulse": {"type": "INTEGER", "description": "Puls in bpm"} - } - }, - "rest_days": { - "description": "Ruhetage", - "columns": { - "id": {"type": "UUID", "description": "ID (für Zählung von Ruhetagen)"} - } - } - } - - return schema - -@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. - """ - try: - 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, filter_conditions, description, is_system, is_active, - 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 - """) - - results = [r2d(row) for row in cur.fetchall()] - print(f"[DEBUG] Loaded {len(results)} goal types") - return results - - except Exception as e: - print(f"[ERROR] list_goal_type_definitions failed: {e}") - print(traceback.format_exc()) - raise HTTPException( - status_code=500, - detail=f"Fehler beim Laden der Goal Types: {str(e)}" - ) - -@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 - import json as json_lib - filter_json = json_lib.dumps(data.filter_conditions) if data.filter_conditions else None - - cur.execute(""" - INSERT INTO goal_type_definitions ( - type_key, label_de, label_en, unit, icon, category, - source_table, source_column, aggregation_method, - calculation_formula, filter_conditions, description, is_active, is_system - ) VALUES (%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, filter_json, data.description, - True, False # is_active=True, is_system=False - )) - - 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.filter_conditions is not None: - import json as json_lib - filter_json = json_lib.dumps(data.filter_conditions) if data.filter_conditions else None - updates.append("filter_conditions = %s") - params.append(filter_json) - - 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()") - 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"} diff --git a/backend/routers/training_phases.py b/backend/routers/training_phases.py new file mode 100644 index 0000000..93e509a --- /dev/null +++ b/backend/routers/training_phases.py @@ -0,0 +1,107 @@ +""" +Training Phases Router - Training Phase Detection & Management + +Endpoints for managing training phases: +- List training phases +- Create manual training phases +- Update phase status (accept/reject auto-detected phases) + +Part of v9h Goal System. +""" +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional +from datetime import date + +from db import get_db, get_cursor, r2d +from auth import require_auth + +router = APIRouter(prefix="/api/goals", tags=["training-phases"]) + +# ============================================================================ +# Pydantic Models +# ============================================================================ + +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 + +# ============================================================================ +# Endpoints +# ============================================================================ + +@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"}