""" 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="/api/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 (dynamic system). Returns focus areas with user-set weights, grouped by category. """ pid = session['profile_id'] with get_db() as conn: cur = get_cursor(conn) # Get dynamic preferences (Migration 032) try: cur.execute(""" SELECT fa.id, fa.key, fa.name_de, fa.name_en, fa.icon, fa.category, fa.description, ufw.weight FROM user_focus_area_weights ufw JOIN focus_area_definitions fa ON ufw.focus_area_id = fa.id WHERE ufw.profile_id = %s AND ufw.weight > 0 ORDER BY fa.category, fa.name_de """, (pid,)) weights = [r2d(row) for row in cur.fetchall()] # Calculate percentages from weights total_weight = sum(w['weight'] for w in weights) if total_weight > 0: for w in weights: w['percentage'] = round((w['weight'] / total_weight) * 100) else: for w in weights: w['percentage'] = 0 # Group by category grouped = {} for w in weights: cat = w['category'] or 'other' if cat not in grouped: grouped[cat] = [] grouped[cat].append(w) return { "weights": weights, "grouped": grouped, "total_weight": total_weight } except Exception as e: # Migration 032 not applied yet - return empty print(f"[WARNING] user_focus_area_weights not found: {e}") return { "weights": [], "grouped": {}, "total_weight": 0 } @router.put("/user-preferences") def update_user_focus_preferences( data: dict, session: dict = Depends(require_auth) ): """ Update user's focus area weightings (dynamic system). Expects: { "weights": { "focus_area_id": weight, ... } } Weights are relative (0-100), normalized in display only. """ pid = session['profile_id'] if 'weights' not in data: raise HTTPException(status_code=400, detail="'weights' field required") weights = data['weights'] # Dict: focus_area_id → weight with get_db() as conn: cur = get_cursor(conn) # Delete existing weights cur.execute( "DELETE FROM user_focus_area_weights WHERE profile_id = %s", (pid,) ) # Insert new weights (only non-zero) for focus_area_id, weight in weights.items(): weight_int = int(weight) if weight_int > 0: cur.execute(""" INSERT INTO user_focus_area_weights (profile_id, focus_area_id, weight) VALUES (%s, %s, %s) ON CONFLICT (profile_id, focus_area_id) DO UPDATE SET weight = EXCLUDED.weight, updated_at = NOW() """, (pid, focus_area_id, weight_int)) return { "message": "Focus Area Gewichtungen aktualisiert", "count": len([w for w in weights.values() if int(w) > 0]) } # ============================================================================ # 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 }