feat: Training Type Profiles Phase 2.1 - Backend Profile Management (#15)
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

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 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-23 11:50:40 +01:00
parent ca7d9b2e3f
commit d7145874cf
2 changed files with 583 additions and 6 deletions

View File

@ -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()
]

View File

@ -11,6 +11,7 @@ from psycopg2.extras import Json
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth, require_admin 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"]) router = APIRouter(prefix="/api/admin/training-types", tags=["admin", "training-types"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -26,6 +27,7 @@ class TrainingTypeCreate(BaseModel):
description_en: Optional[str] = None description_en: Optional[str] = None
sort_order: int = 0 sort_order: int = 0
abilities: Optional[dict] = None abilities: Optional[dict] = None
profile: Optional[dict] = None # Training Type Profile (Phase 2 #15)
class TrainingTypeUpdate(BaseModel): class TrainingTypeUpdate(BaseModel):
@ -38,6 +40,7 @@ class TrainingTypeUpdate(BaseModel):
description_en: Optional[str] = None description_en: Optional[str] = None
sort_order: Optional[int] = None sort_order: Optional[int] = None
abilities: Optional[dict] = None abilities: Optional[dict] = None
profile: Optional[dict] = None # Training Type Profile (Phase 2 #15)
@router.get("") @router.get("")
@ -51,7 +54,7 @@ def list_training_types_admin(session: dict = Depends(require_admin)):
cur.execute(""" cur.execute("""
SELECT id, category, subcategory, name_de, name_en, icon, SELECT id, category, subcategory, name_de, name_en, icon,
description_de, description_en, sort_order, abilities, description_de, description_en, sort_order, abilities,
created_at profile, created_at
FROM training_types FROM training_types
ORDER BY sort_order, category, subcategory ORDER BY sort_order, category, subcategory
""") """)
@ -68,7 +71,7 @@ def get_training_type(type_id: int, session: dict = Depends(require_admin)):
cur.execute(""" cur.execute("""
SELECT id, category, subcategory, name_de, name_en, icon, SELECT id, category, subcategory, name_de, name_en, icon,
description_de, description_en, sort_order, abilities, description_de, description_en, sort_order, abilities,
created_at profile, created_at
FROM training_types FROM training_types
WHERE id = %s WHERE id = %s
""", (type_id,)) """, (type_id,))
@ -86,14 +89,15 @@ def create_training_type(data: TrainingTypeCreate, session: dict = Depends(requi
with get_db() as conn: with get_db() as conn:
cur = get_cursor(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 {} abilities_json = data.abilities if data.abilities else {}
profile_json = data.profile if data.profile else None
cur.execute(""" cur.execute("""
INSERT INTO training_types INSERT INTO training_types
(category, subcategory, name_de, name_en, icon, (category, subcategory, name_de, name_en, icon,
description_de, description_en, sort_order, abilities) description_de, description_en, sort_order, abilities, profile)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id RETURNING id
""", ( """, (
data.category, data.category,
@ -104,7 +108,8 @@ def create_training_type(data: TrainingTypeCreate, session: dict = Depends(requi
data.description_de, data.description_de,
data.description_en, data.description_en,
data.sort_order, data.sort_order,
Json(abilities_json) Json(abilities_json),
Json(profile_json) if profile_json else None
)) ))
new_id = cur.fetchone()['id'] new_id = cur.fetchone()['id']
@ -155,6 +160,9 @@ def update_training_type(
if data.abilities is not None: if data.abilities is not None:
updates.append("abilities = %s") updates.append("abilities = %s")
values.append(Json(data.abilities)) values.append(Json(data.abilities))
if data.profile is not None:
updates.append("profile = %s")
values.append(Json(data.profile))
if not updates: if not updates:
raise HTTPException(400, "No fields to update") raise HTTPException(400, "No fields to update")
@ -280,3 +288,122 @@ def get_abilities_taxonomy(session: dict = Depends(require_auth)):
} }
return taxonomy 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
}