From d7145874cf217454e4f91a5eb3c5f2e597efa015 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 23 Mar 2026 11:50:40 +0100 Subject: [PATCH] feat: Training Type Profiles Phase 2.1 - Backend Profile Management (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin endpoints for profile configuration: - Extended TrainingTypeCreate/Update models with profile field - Added profile column to all SELECT queries - Profile templates for Running, Meditation, Strength Training - Template endpoints: list, get, apply - Profile stats endpoint (configured/unconfigured count) New file: profile_templates.py - TEMPLATE_RUNNING: Endurance-focused with HR zones - TEMPLATE_MEDITATION: Mental-focused (low HR ≤ instead of ≥) - TEMPLATE_STRENGTH: Strength-focused API Endpoints: - GET /api/admin/training-types/profiles/templates - GET /api/admin/training-types/profiles/templates/{key} - POST /api/admin/training-types/{id}/profile/apply-template - GET /api/admin/training-types/profiles/stats Next: Frontend Admin-UI (ProfileEditor component) Co-Authored-By: Claude Opus 4.6 --- backend/profile_templates.py | 450 ++++++++++++++++++++++++ backend/routers/admin_training_types.py | 139 +++++++- 2 files changed, 583 insertions(+), 6 deletions(-) create mode 100644 backend/profile_templates.py diff --git a/backend/profile_templates.py b/backend/profile_templates.py new file mode 100644 index 0000000..0690292 --- /dev/null +++ b/backend/profile_templates.py @@ -0,0 +1,450 @@ +""" +Training Type Profile Templates +Pre-configured profiles for common training types. + +Issue: #15 +Date: 2026-03-23 +""" + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# TEMPLATE: LAUFEN (Running) - Ausdauer-fokussiert +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +TEMPLATE_RUNNING = { + "version": "1.0", + "name": "Laufen (Standard)", + "description": "Ausdauerlauf mit Herzfrequenz-Zonen", + + "rule_sets": { + "minimum_requirements": { + "enabled": True, + "pass_strategy": "weighted_score", + "pass_threshold": 0.6, + "rules": [ + { + "parameter": "duration_min", + "operator": "gte", + "value": 15, + "weight": 5, + "optional": False, + "reason": "Mindestens 15 Minuten für Trainingseffekt" + }, + { + "parameter": "avg_hr", + "operator": "gte", + "value": 100, + "weight": 3, + "optional": False, + "reason": "Puls muss für Ausdauerreiz erhöht sein" + }, + { + "parameter": "distance_km", + "operator": "gte", + "value": 1.0, + "weight": 2, + "optional": False, + "reason": "Mindestens 1 km Distanz" + } + ] + }, + + "intensity_zones": { + "enabled": True, + "zones": [ + { + "id": "regeneration", + "name": "Regeneration", + "color": "#4CAF50", + "effect": "Aktive Erholung", + "target_duration_min": 30, + "rules": [ + { + "parameter": "avg_hr_percent", + "operator": "between", + "value": [50, 60] + } + ] + }, + { + "id": "grundlagenausdauer", + "name": "Grundlagenausdauer", + "color": "#2196F3", + "effect": "Fettverbrennung, aerobe Basis", + "target_duration_min": 45, + "rules": [ + { + "parameter": "avg_hr_percent", + "operator": "between", + "value": [60, 70] + } + ] + }, + { + "id": "entwicklungsbereich", + "name": "Entwicklungsbereich", + "color": "#FF9800", + "effect": "VO2max-Training, Laktattoleranz", + "target_duration_min": 30, + "rules": [ + { + "parameter": "avg_hr_percent", + "operator": "between", + "value": [70, 80] + } + ] + }, + { + "id": "schwellentraining", + "name": "Schwellentraining", + "color": "#F44336", + "effect": "Anaerobe Schwelle, Wettkampftempo", + "target_duration_min": 20, + "rules": [ + { + "parameter": "avg_hr_percent", + "operator": "between", + "value": [80, 90] + } + ] + } + ] + }, + + "training_effects": { + "enabled": True, + "default_effects": { + "primary_abilities": [ + { + "category": "konditionell", + "ability": "ausdauer", + "intensity": 5 + } + ], + "secondary_abilities": [ + { + "category": "konditionell", + "ability": "schnelligkeit", + "intensity": 2 + }, + { + "category": "koordinativ", + "ability": "rhythmus", + "intensity": 3 + }, + { + "category": "psychisch", + "ability": "willenskraft", + "intensity": 4 + } + ] + }, + "metabolic_focus": ["aerobic", "fat_oxidation"], + "muscle_groups": ["legs", "core", "cardiovascular"] + }, + + "periodization": { + "enabled": True, + "frequency": { + "per_week_optimal": 3, + "per_week_max": 5 + }, + "recovery": { + "min_hours_between": 24 + } + }, + + "performance_indicators": { + "enabled": False + }, + + "safety": { + "enabled": True, + "warnings": [ + { + "parameter": "avg_hr_percent", + "operator": "gt", + "value": 95, + "severity": "high", + "message": "Herzfrequenz zu hoch - Überbelastungsrisiko" + }, + { + "parameter": "duration_min", + "operator": "gt", + "value": 180, + "severity": "medium", + "message": "Sehr lange Einheit - achte auf Regeneration" + } + ] + } + } +} + + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# TEMPLATE: MEDITATION - Mental-fokussiert (≤ statt ≥ bei HR!) +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +TEMPLATE_MEDITATION = { + "version": "1.0", + "name": "Meditation (Standard)", + "description": "Mentales Training mit niedrigem Puls", + + "rule_sets": { + "minimum_requirements": { + "enabled": True, + "pass_strategy": "weighted_score", + "pass_threshold": 0.6, + "rules": [ + { + "parameter": "duration_min", + "operator": "gte", + "value": 5, + "weight": 5, + "optional": False, + "reason": "Mindestens 5 Minuten für Entspannungseffekt" + }, + { + "parameter": "avg_hr", + "operator": "lte", + "value": 80, + "weight": 4, + "optional": False, + "reason": "Niedriger Puls zeigt Entspannung an" + } + ] + }, + + "intensity_zones": { + "enabled": True, + "zones": [ + { + "id": "deep_relaxation", + "name": "Tiefenentspannung", + "color": "#4CAF50", + "effect": "Parasympathikus-Aktivierung", + "target_duration_min": 10, + "rules": [ + { + "parameter": "avg_hr_percent", + "operator": "between", + "value": [35, 45] + } + ] + }, + { + "id": "light_meditation", + "name": "Leichte Meditation", + "color": "#2196F3", + "effect": "Achtsamkeit, Fokus", + "target_duration_min": 15, + "rules": [ + { + "parameter": "avg_hr_percent", + "operator": "between", + "value": [45, 55] + } + ] + } + ] + }, + + "training_effects": { + "enabled": True, + "default_effects": { + "primary_abilities": [ + { + "category": "kognitiv", + "ability": "konzentration", + "intensity": 5 + }, + { + "category": "psychisch", + "ability": "stressresistenz", + "intensity": 5 + } + ], + "secondary_abilities": [ + { + "category": "kognitiv", + "ability": "wahrnehmung", + "intensity": 4 + }, + { + "category": "psychisch", + "ability": "selbstvertrauen", + "intensity": 3 + } + ] + }, + "metabolic_focus": ["parasympathetic_activation"], + "muscle_groups": [] + }, + + "periodization": { + "enabled": True, + "frequency": { + "per_week_optimal": 5, + "per_week_max": 7 + }, + "recovery": { + "min_hours_between": 0 + } + }, + + "performance_indicators": { + "enabled": False + }, + + "safety": { + "enabled": True, + "warnings": [ + { + "parameter": "avg_hr", + "operator": "gt", + "value": 100, + "severity": "medium", + "message": "Herzfrequenz zu hoch für Meditation - bist du wirklich entspannt?" + } + ] + } + } +} + + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# TEMPLATE: KRAFTTRAINING - Kraft-fokussiert +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +TEMPLATE_STRENGTH = { + "version": "1.0", + "name": "Krafttraining (Standard)", + "description": "Krafttraining mit moderater Herzfrequenz", + + "rule_sets": { + "minimum_requirements": { + "enabled": True, + "pass_strategy": "weighted_score", + "pass_threshold": 0.5, + "rules": [ + { + "parameter": "duration_min", + "operator": "gte", + "value": 20, + "weight": 5, + "optional": False, + "reason": "Mindestens 20 Minuten für Muskelreiz" + }, + { + "parameter": "kcal_active", + "operator": "gte", + "value": 100, + "weight": 2, + "optional": True, + "reason": "Mindest-Kalorienverbrauch" + } + ] + }, + + "intensity_zones": { + "enabled": False + }, + + "training_effects": { + "enabled": True, + "default_effects": { + "primary_abilities": [ + { + "category": "konditionell", + "ability": "kraft", + "intensity": 5 + } + ], + "secondary_abilities": [ + { + "category": "koordinativ", + "ability": "differenzierung", + "intensity": 3 + }, + { + "category": "psychisch", + "ability": "willenskraft", + "intensity": 4 + } + ] + }, + "metabolic_focus": ["anaerobic", "muscle_growth"], + "muscle_groups": ["full_body"] + }, + + "periodization": { + "enabled": True, + "frequency": { + "per_week_optimal": 3, + "per_week_max": 5 + }, + "recovery": { + "min_hours_between": 48 + } + }, + + "performance_indicators": { + "enabled": False + }, + + "safety": { + "enabled": True, + "warnings": [] + } + } +} + + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# TEMPLATE REGISTRY +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +TEMPLATES = { + "running": { + "name_de": "Laufen", + "name_en": "Running", + "icon": "🏃", + "categories": ["cardio", "running"], + "template": TEMPLATE_RUNNING + }, + "meditation": { + "name_de": "Meditation", + "name_en": "Meditation", + "icon": "🧘", + "categories": ["geist", "meditation"], + "template": TEMPLATE_MEDITATION + }, + "strength": { + "name_de": "Krafttraining", + "name_en": "Strength Training", + "icon": "💪", + "categories": ["kraft", "krafttraining"], + "template": TEMPLATE_STRENGTH + } +} + + +def get_template(template_key: str) -> dict: + """Get profile template by key.""" + template_info = TEMPLATES.get(template_key) + if not template_info: + return None + return template_info["template"] + + +def list_templates() -> list: + """List all available templates.""" + return [ + { + "key": key, + "name_de": info["name_de"], + "name_en": info["name_en"], + "icon": info["icon"], + "categories": info["categories"] + } + for key, info in TEMPLATES.items() + ] diff --git a/backend/routers/admin_training_types.py b/backend/routers/admin_training_types.py index 49899ed..3366ed0 100644 --- a/backend/routers/admin_training_types.py +++ b/backend/routers/admin_training_types.py @@ -11,6 +11,7 @@ from psycopg2.extras import Json from db import get_db, get_cursor, r2d from auth import require_auth, require_admin +from profile_templates import list_templates, get_template router = APIRouter(prefix="/api/admin/training-types", tags=["admin", "training-types"]) logger = logging.getLogger(__name__) @@ -26,6 +27,7 @@ class TrainingTypeCreate(BaseModel): description_en: Optional[str] = None sort_order: int = 0 abilities: Optional[dict] = None + profile: Optional[dict] = None # Training Type Profile (Phase 2 #15) class TrainingTypeUpdate(BaseModel): @@ -38,6 +40,7 @@ class TrainingTypeUpdate(BaseModel): description_en: Optional[str] = None sort_order: Optional[int] = None abilities: Optional[dict] = None + profile: Optional[dict] = None # Training Type Profile (Phase 2 #15) @router.get("") @@ -51,7 +54,7 @@ def list_training_types_admin(session: dict = Depends(require_admin)): cur.execute(""" SELECT id, category, subcategory, name_de, name_en, icon, description_de, description_en, sort_order, abilities, - created_at + profile, created_at FROM training_types ORDER BY sort_order, category, subcategory """) @@ -68,7 +71,7 @@ def get_training_type(type_id: int, session: dict = Depends(require_admin)): cur.execute(""" SELECT id, category, subcategory, name_de, name_en, icon, description_de, description_en, sort_order, abilities, - created_at + profile, created_at FROM training_types WHERE id = %s """, (type_id,)) @@ -86,14 +89,15 @@ def create_training_type(data: TrainingTypeCreate, session: dict = Depends(requi with get_db() as conn: cur = get_cursor(conn) - # Convert abilities dict to JSONB + # Convert abilities and profile dict to JSONB abilities_json = data.abilities if data.abilities else {} + profile_json = data.profile if data.profile else None cur.execute(""" INSERT INTO training_types (category, subcategory, name_de, name_en, icon, - description_de, description_en, sort_order, abilities) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + description_de, description_en, sort_order, abilities, profile) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( data.category, @@ -104,7 +108,8 @@ def create_training_type(data: TrainingTypeCreate, session: dict = Depends(requi data.description_de, data.description_en, data.sort_order, - Json(abilities_json) + Json(abilities_json), + Json(profile_json) if profile_json else None )) new_id = cur.fetchone()['id'] @@ -155,6 +160,9 @@ def update_training_type( if data.abilities is not None: updates.append("abilities = %s") values.append(Json(data.abilities)) + if data.profile is not None: + updates.append("profile = %s") + values.append(Json(data.profile)) if not updates: raise HTTPException(400, "No fields to update") @@ -280,3 +288,122 @@ def get_abilities_taxonomy(session: dict = Depends(require_auth)): } return taxonomy + + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# TRAINING TYPE PROFILES - Phase 2 (#15) +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +@router.get("/profiles/templates") +def list_profile_templates(session: dict = Depends(require_admin)): + """ + List all available profile templates. + + Returns templates for common training types (Running, Meditation, Strength, etc.) + """ + return list_templates() + + +@router.get("/profiles/templates/{template_key}") +def get_profile_template(template_key: str, session: dict = Depends(require_admin)): + """ + Get a specific profile template by key. + + Keys: running, meditation, strength + """ + template = get_template(template_key) + if not template: + raise HTTPException(404, f"Template '{template_key}' not found") + return template + + +@router.post("/{type_id}/profile/apply-template") +def apply_profile_template( + type_id: int, + data: dict, + session: dict = Depends(require_admin) +): + """ + Apply a profile template to a training type. + + Body: { "template_key": "running" } + """ + template_key = data.get("template_key") + if not template_key: + raise HTTPException(400, "template_key required") + + template = get_template(template_key) + if not template: + raise HTTPException(404, f"Template '{template_key}' not found") + + # Apply template to training type + with get_db() as conn: + cur = get_cursor(conn) + + # Check if training type exists + cur.execute("SELECT id, name_de FROM training_types WHERE id = %s", (type_id,)) + training_type = cur.fetchone() + if not training_type: + raise HTTPException(404, "Training type not found") + + # Update profile + cur.execute(""" + UPDATE training_types + SET profile = %s + WHERE id = %s + """, (Json(template), type_id)) + + logger.info(f"[ADMIN] Applied template '{template_key}' to training type {type_id} ({training_type['name_de']})") + + return { + "message": f"Template '{template_key}' applied successfully", + "training_type_id": type_id, + "training_type_name": training_type['name_de'], + "template_key": template_key + } + + +@router.get("/profiles/stats") +def get_profile_stats(session: dict = Depends(require_admin)): + """ + Get statistics about configured profiles. + + Returns count of training types with/without profiles. + """ + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute(""" + SELECT + COUNT(*) as total, + COUNT(profile) as configured, + COUNT(*) - COUNT(profile) as unconfigured + FROM training_types + """) + stats = cur.fetchone() + + # Get list of types with profiles + cur.execute(""" + SELECT id, name_de, category, subcategory + FROM training_types + WHERE profile IS NOT NULL + ORDER BY name_de + """) + configured_types = [r2d(r) for r in cur.fetchall()] + + # Get list of types without profiles + cur.execute(""" + SELECT id, name_de, category, subcategory + FROM training_types + WHERE profile IS NULL + ORDER BY name_de + """) + unconfigured_types = [r2d(r) for r in cur.fetchall()] + + return { + "total": stats['total'], + "configured": stats['configured'], + "unconfigured": stats['unconfigured'], + "configured_types": configured_types, + "unconfigured_types": unconfigured_types + }