mitai-jinkendo/backend/routers/focus_areas.py
Lars f312dd0dbb
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
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>
2026-03-27 19:48:05 +01:00

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
}