feat: Backend Phase 2 - Focus Areas API + Goals integration
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

**New Router: focus_areas.py**
- GET /focus-areas/definitions (list all, grouped by category)
- POST/PUT/DELETE /focus-areas/definitions (Admin CRUD)
- GET /focus-areas/user-preferences (legacy + future dynamic)
- PUT /focus-areas/user-preferences (auto-normalize to 100%)
- GET /focus-areas/stats (progress per focus area)

**Goals Router Extended:**
- FocusContribution model (focus_area_id + contribution_weight)
- GoalCreate/Update: focus_contributions field
- create_goal: Insert contributions after goal creation
- update_goal: Delete old + insert new contributions
- get_goals_grouped: Load focus_contributions per goal

**Main.py:**
- Registered focus_areas router

**Features:**
- Many-to-Many mapping (goals ↔ focus areas)
- Contribution weights (0-100%)
- Auto-mapped by Migration 031
- User can edit via UI (next: frontend)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-27 19:48:05 +01:00
parent 2f64656d4d
commit f312dd0dbb
3 changed files with 462 additions and 10 deletions

View File

@ -23,7 +23,7 @@ from routers import user_restrictions, access_grants, training_types, admin_trai
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 # v9e Goal System (Strategic + Tactical)
from routers import goals, focus_areas # v9e/v9g Goal System v2.0 (Dynamic Focus Areas)
# ── App Configuration ─────────────────────────────────────────────────────────
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
@ -99,6 +99,7 @@ app.include_router(vitals_baseline.router) # /api/vitals/baseline/* (v9d Ph
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(focus_areas.router) # /api/focus-areas/* (v9g Focus Area System v2.0 - Dynamic)
# ── Health Check ──────────────────────────────────────────────────────────────
@app.get("/")

View File

@ -0,0 +1,384 @@
"""
Focus Areas Router
Manages dynamic focus area definitions and user preferences
"""
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional, List
from db import get_db, get_cursor, r2d
from auth import require_auth
router = APIRouter(prefix="/focus-areas", tags=["focus-areas"])
# ============================================================================
# Models
# ============================================================================
class FocusAreaCreate(BaseModel):
"""Create new focus area definition"""
key: str
name_de: str
name_en: Optional[str] = None
icon: Optional[str] = None
description: Optional[str] = None
category: str = 'custom'
class FocusAreaUpdate(BaseModel):
"""Update focus area definition"""
name_de: Optional[str] = None
name_en: Optional[str] = None
icon: Optional[str] = None
description: Optional[str] = None
category: Optional[str] = None
is_active: Optional[bool] = None
class UserFocusPreferences(BaseModel):
"""User's focus area weightings (dynamic)"""
preferences: dict # {focus_area_id: weight_pct}
# ============================================================================
# Focus Area Definitions (Admin)
# ============================================================================
@router.get("/definitions")
def list_focus_area_definitions(
session: dict = Depends(require_auth),
include_inactive: bool = False
):
"""
List all available focus area definitions.
Query params:
- include_inactive: Include inactive focus areas (default: false)
Returns focus areas grouped by category.
"""
with get_db() as conn:
cur = get_cursor(conn)
query = """
SELECT id, key, name_de, name_en, icon, description, category, is_active,
created_at, updated_at
FROM focus_area_definitions
WHERE is_active = true OR %s
ORDER BY category, name_de
"""
cur.execute(query, (include_inactive,))
areas = [r2d(row) for row in cur.fetchall()]
# Group by category
grouped = {}
for area in areas:
cat = area['category'] or 'other'
if cat not in grouped:
grouped[cat] = []
grouped[cat].append(area)
return {
"areas": areas,
"grouped": grouped,
"total": len(areas)
}
@router.post("/definitions")
def create_focus_area_definition(
data: FocusAreaCreate,
session: dict = Depends(require_auth)
):
"""
Create new focus area definition (Admin only).
Note: Requires admin role.
"""
# Admin check
if session.get('role') != 'admin':
raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich")
with get_db() as conn:
cur = get_cursor(conn)
# Check if key already exists
cur.execute(
"SELECT id FROM focus_area_definitions WHERE key = %s",
(data.key,)
)
if cur.fetchone():
raise HTTPException(
status_code=400,
detail=f"Focus Area mit Key '{data.key}' existiert bereits"
)
# Insert
cur.execute("""
INSERT INTO focus_area_definitions
(key, name_de, name_en, icon, description, category)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""", (
data.key, data.name_de, data.name_en,
data.icon, data.description, data.category
))
area_id = cur.fetchone()['id']
return {
"id": area_id,
"message": f"Focus Area '{data.name_de}' erstellt"
}
@router.put("/definitions/{area_id}")
def update_focus_area_definition(
area_id: str,
data: FocusAreaUpdate,
session: dict = Depends(require_auth)
):
"""Update focus area definition (Admin only)"""
# Admin check
if session.get('role') != 'admin':
raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich")
with get_db() as conn:
cur = get_cursor(conn)
# Build dynamic UPDATE
updates = []
values = []
if data.name_de is not None:
updates.append("name_de = %s")
values.append(data.name_de)
if data.name_en is not None:
updates.append("name_en = %s")
values.append(data.name_en)
if data.icon is not None:
updates.append("icon = %s")
values.append(data.icon)
if data.description is not None:
updates.append("description = %s")
values.append(data.description)
if data.category is not None:
updates.append("category = %s")
values.append(data.category)
if data.is_active is not None:
updates.append("is_active = %s")
values.append(data.is_active)
if not updates:
raise HTTPException(status_code=400, detail="Keine Änderungen angegeben")
updates.append("updated_at = NOW()")
values.append(area_id)
query = f"""
UPDATE focus_area_definitions
SET {', '.join(updates)}
WHERE id = %s
RETURNING id
"""
cur.execute(query, values)
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Focus Area nicht gefunden")
return {"message": "Focus Area aktualisiert"}
@router.delete("/definitions/{area_id}")
def delete_focus_area_definition(
area_id: str,
session: dict = Depends(require_auth)
):
"""
Delete focus area definition (Admin only).
Cascades: Deletes all goal_focus_contributions referencing this area.
"""
# Admin check
if session.get('role') != 'admin':
raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich")
with get_db() as conn:
cur = get_cursor(conn)
# Check if area is used
cur.execute(
"SELECT COUNT(*) as count FROM goal_focus_contributions WHERE focus_area_id = %s",
(area_id,)
)
count = cur.fetchone()['count']
if count > 0:
raise HTTPException(
status_code=400,
detail=f"Focus Area wird von {count} Ziel(en) verwendet. "
"Bitte erst Zuordnungen entfernen oder auf 'inaktiv' setzen."
)
# Delete
cur.execute(
"DELETE FROM focus_area_definitions WHERE id = %s RETURNING id",
(area_id,)
)
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Focus Area nicht gefunden")
return {"message": "Focus Area gelöscht"}
# ============================================================================
# User Focus Preferences
# ============================================================================
@router.get("/user-preferences")
def get_user_focus_preferences(session: dict = Depends(require_auth)):
"""
Get user's focus area weightings.
Returns:
- legacy: Old flat structure (weight_loss_pct, muscle_gain_pct, etc.)
- dynamic: New dynamic preferences (focus_area_id weight_pct)
"""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
# Get legacy preferences
cur.execute("""
SELECT weight_loss_pct, muscle_gain_pct, strength_pct,
endurance_pct, flexibility_pct, health_pct
FROM user_focus_preferences
WHERE profile_id = %s
""", (pid,))
legacy = cur.fetchone()
if legacy:
legacy = r2d(legacy)
else:
# Create default if not exists
cur.execute("""
INSERT INTO user_focus_preferences (profile_id)
VALUES (%s)
RETURNING weight_loss_pct, muscle_gain_pct, strength_pct,
endurance_pct, flexibility_pct, health_pct
""", (pid,))
legacy = r2d(cur.fetchone())
# TODO: Future - dynamic preferences from new table
# For now, return legacy structure
return {
"legacy": legacy,
"dynamic": {} # Placeholder for future
}
@router.put("/user-preferences")
def update_user_focus_preferences(
data: dict,
session: dict = Depends(require_auth)
):
"""
Update user's focus area weightings.
Accepts flat structure (legacy) for now.
Auto-normalizes to sum=100%.
"""
pid = session['profile_id']
# Extract percentages
percentages = {
'weight_loss_pct': data.get('weight_loss_pct', 0),
'muscle_gain_pct': data.get('muscle_gain_pct', 0),
'strength_pct': data.get('strength_pct', 0),
'endurance_pct': data.get('endurance_pct', 0),
'flexibility_pct': data.get('flexibility_pct', 0),
'health_pct': data.get('health_pct', 0)
}
# Normalize to 100%
total = sum(percentages.values())
if total > 0:
for key in percentages:
percentages[key] = round((percentages[key] / total) * 100)
# Adjust largest if sum != 100 due to rounding
current_sum = sum(percentages.values())
if current_sum != 100 and total > 0:
largest_key = max(percentages, key=percentages.get)
percentages[largest_key] += (100 - current_sum)
with get_db() as conn:
cur = get_cursor(conn)
# Upsert
cur.execute("""
INSERT INTO user_focus_preferences
(profile_id, weight_loss_pct, muscle_gain_pct, strength_pct,
endurance_pct, flexibility_pct, health_pct)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (profile_id)
DO UPDATE SET
weight_loss_pct = EXCLUDED.weight_loss_pct,
muscle_gain_pct = EXCLUDED.muscle_gain_pct,
strength_pct = EXCLUDED.strength_pct,
endurance_pct = EXCLUDED.endurance_pct,
flexibility_pct = EXCLUDED.flexibility_pct,
health_pct = EXCLUDED.health_pct,
updated_at = NOW()
""", (
pid,
percentages['weight_loss_pct'],
percentages['muscle_gain_pct'],
percentages['strength_pct'],
percentages['endurance_pct'],
percentages['flexibility_pct'],
percentages['health_pct']
))
return {
"message": "Focus Areas aktualisiert",
"normalized": percentages
}
# ============================================================================
# Stats & Analytics
# ============================================================================
@router.get("/stats")
def get_focus_area_stats(session: dict = Depends(require_auth)):
"""
Get focus area statistics for current user.
Returns:
- Progress per focus area (avg of all contributing goals)
- Goal count per focus area
- Top/bottom performing areas
"""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT
fa.id, fa.key, fa.name_de, fa.icon, fa.category,
COUNT(DISTINCT gfc.goal_id) as goal_count,
AVG(g.progress_pct) as avg_progress,
SUM(gfc.contribution_weight) as total_contribution
FROM focus_area_definitions fa
LEFT JOIN goal_focus_contributions gfc ON fa.id = gfc.focus_area_id
LEFT JOIN goals g ON gfc.goal_id = g.id AND g.profile_id = %s
WHERE fa.is_active = true
GROUP BY fa.id
HAVING COUNT(DISTINCT gfc.goal_id) > 0 -- Only areas with goals
ORDER BY avg_progress DESC NULLS LAST
""", (pid,))
stats = [r2d(row) for row in cur.fetchall()]
return {
"stats": stats,
"top_area": stats[0] if stats else None,
"bottom_area": stats[-1] if len(stats) > 1 else None
}

View File

@ -39,6 +39,11 @@ class FocusAreasUpdate(BaseModel):
flexibility_pct: int
health_pct: int
class FocusContribution(BaseModel):
"""Focus area contribution (v2.0)"""
focus_area_id: str
contribution_weight: float = 100.0 # 0-100%
class GoalCreate(BaseModel):
"""Create or update a concrete goal"""
goal_type: str # weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr
@ -50,6 +55,7 @@ class GoalCreate(BaseModel):
priority: Optional[int] = 2 # 1=high, 2=medium, 3=low
name: Optional[str] = None
description: Optional[str] = None
focus_contributions: Optional[List[FocusContribution]] = [] # v2.0: Many-to-Many
class GoalUpdate(BaseModel):
"""Update existing goal"""
@ -61,6 +67,7 @@ class GoalUpdate(BaseModel):
priority: Optional[int] = None # 1=high, 2=medium, 3=low
name: Optional[str] = None
description: Optional[str] = None
focus_contributions: Optional[List[FocusContribution]] = None # v2.0: Many-to-Many
class TrainingPhaseCreate(BaseModel):
"""Create training phase (manual or auto-detected)"""
@ -429,6 +436,17 @@ def create_goal(data: GoalCreate, session: dict = Depends(require_auth)):
goal_id = cur.fetchone()['id']
# v2.0: Insert focus area contributions
if data.focus_contributions:
for contrib in data.focus_contributions:
cur.execute("""
INSERT INTO goal_focus_contributions
(goal_id, focus_area_id, contribution_weight)
VALUES (%s, %s, %s)
ON CONFLICT (goal_id, focus_area_id) DO UPDATE
SET contribution_weight = EXCLUDED.contribution_weight
""", (goal_id, contrib.focus_area_id, contrib.contribution_weight))
return {"id": goal_id, "message": "Ziel erstellt"}
@router.put("/{goal_id}")
@ -492,16 +510,33 @@ def update_goal(goal_id: str, data: GoalUpdate, session: dict = Depends(require_
updates.append("description = %s")
params.append(data.description)
if not updates:
# Handle focus_contributions separately (can be updated even if no other changes)
if data.focus_contributions is not None:
# Delete existing contributions
cur.execute(
"DELETE FROM goal_focus_contributions WHERE goal_id = %s",
(goal_id,)
)
# Insert new contributions
for contrib in data.focus_contributions:
cur.execute("""
INSERT INTO goal_focus_contributions
(goal_id, focus_area_id, contribution_weight)
VALUES (%s, %s, %s)
""", (goal_id, contrib.focus_area_id, contrib.contribution_weight))
if not updates and data.focus_contributions is None:
raise HTTPException(status_code=400, detail="Keine Änderungen angegeben")
updates.append("updated_at = NOW()")
params.extend([goal_id, pid])
if updates:
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)
)
cur.execute(
f"UPDATE goals SET {', '.join(updates)} WHERE id = %s AND profile_id = %s",
tuple(params)
)
return {"message": "Ziel aktualisiert"}
@ -680,13 +715,45 @@ def get_goals_grouped(session: dict = Depends(require_auth)):
goals = cur.fetchall()
# Group by category
# v2.0: Load focus_contributions for each goal
goal_ids = [g['id'] for g in goals]
focus_map = {} # goal_id → [contributions]
if goal_ids:
placeholders = ','.join(['%s'] * len(goal_ids))
cur.execute(f"""
SELECT
gfc.goal_id, gfc.contribution_weight,
fa.id as focus_area_id, fa.key, fa.name_de, fa.icon, fa.category
FROM goal_focus_contributions gfc
JOIN focus_area_definitions fa ON gfc.focus_area_id = fa.id
WHERE gfc.goal_id IN ({placeholders})
ORDER BY gfc.contribution_weight DESC
""", tuple(goal_ids))
for row in cur.fetchall():
gid = row['goal_id']
if gid not in focus_map:
focus_map[gid] = []
focus_map[gid].append({
'focus_area_id': row['focus_area_id'],
'key': row['key'],
'name_de': row['name_de'],
'icon': row['icon'],
'category': row['category'],
'contribution_weight': float(row['contribution_weight'])
})
# Group by category and attach focus_contributions
grouped = {}
for goal in goals:
cat = goal['category'] or 'other'
if cat not in grouped:
grouped[cat] = []
grouped[cat].append(r2d(goal))
goal_dict = r2d(goal)
goal_dict['focus_contributions'] = focus_map.get(goal['id'], [])
grouped[cat].append(goal_dict)
return grouped