**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>
385 lines
12 KiB
Python
385 lines
12 KiB
Python
"""
|
|
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
|
|
}
|