feat: Backend Phase 2 - Focus Areas API + Goals integration
**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:
parent
2f64656d4d
commit
f312dd0dbb
|
|
@ -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("/")
|
||||
|
|
|
|||
384
backend/routers/focus_areas.py
Normal file
384
backend/routers/focus_areas.py
Normal 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
|
||||
}
|
||||
|
|
@ -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,9 +510,26 @@ 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")
|
||||
|
||||
if updates:
|
||||
updates.append("updated_at = NOW()")
|
||||
params.extend([goal_id, pid])
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user