diff --git a/backend/focus_area_usage_helpers.py b/backend/focus_area_usage_helpers.py new file mode 100644 index 0000000..52d5695 --- /dev/null +++ b/backend/focus_area_usage_helpers.py @@ -0,0 +1,20 @@ +"""Kleine Helfer für Focus-Area-Nutzungstypen (ohne Router-/Auth-Abhängigkeiten).""" +from __future__ import annotations + +import json +from typing import Any, List + + +def coerce_usage_type_keys(raw: Any) -> List[str]: + """json_agg / JSON-Spalte zu list[str] normalisieren.""" + if raw is None: + return [] + if isinstance(raw, list): + return [str(x) for x in raw] + if isinstance(raw, str): + try: + data = json.loads(raw) + return [str(x) for x in data] if isinstance(data, list) else [] + except json.JSONDecodeError: + return [] + return [] diff --git a/backend/migrations/036_focus_area_usage_types.sql b/backend/migrations/036_focus_area_usage_types.sql new file mode 100644 index 0000000..566fa68 --- /dev/null +++ b/backend/migrations/036_focus_area_usage_types.sql @@ -0,0 +1,36 @@ +-- Migration 036: Focus Area — erlaubte Nutzungstypen (Referenz + M:N) +-- Date: 2026-04-06 +-- Purpose: System-seeded usage types; optional Zuordnung pro Focus Area (kein Auto-Backfill) + +-- Referenztabelle: feste, systemdefinierte Nutzungstypen +CREATE TABLE IF NOT EXISTS focus_area_usage_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key VARCHAR(64) UNIQUE NOT NULL, + label_de VARCHAR(160), + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE focus_area_usage_types IS + 'Systemdefinierte Nutzungsarten für Focus Areas (kein Admin-CRUD in v1)'; + +-- M:N: welche Nutzungstypen für eine Focus Area erlaubt sind (leer = noch nicht klassifiziert) +CREATE TABLE IF NOT EXISTS focus_area_definition_usage_types ( + focus_area_id UUID NOT NULL REFERENCES focus_area_definitions(id) ON DELETE CASCADE, + usage_type_id UUID NOT NULL REFERENCES focus_area_usage_types(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + PRIMARY KEY (focus_area_id, usage_type_id) +); + +CREATE INDEX IF NOT EXISTS idx_fadut_focus_area ON focus_area_definition_usage_types(focus_area_id); +CREATE INDEX IF NOT EXISTS idx_fadut_usage_type ON focus_area_definition_usage_types(usage_type_id); + +COMMENT ON TABLE focus_area_definition_usage_types IS + 'Zuordnung Focus Area → erlaubte Nutzungstypen (kein automatisches Befüllen bestehender Areas)'; + +-- Seed: nur die drei Typen — keine Zeilen in der Junction-Tabelle +INSERT INTO focus_area_usage_types (key, label_de, sort_order) VALUES + ('goal_priority', 'Ziele / Prioritäten', 1), + ('expected_training_effect', 'Erwartetes Trainingseffekt-Profil', 2), + ('concrete_training_contribution', 'Konkrete Trainings-Beiträge / Belastungsausprägung', 3) +ON CONFLICT (key) DO NOTHING; diff --git a/backend/routers/focus_areas.py b/backend/routers/focus_areas.py index d41d6b0..cfc70b4 100644 --- a/backend/routers/focus_areas.py +++ b/backend/routers/focus_areas.py @@ -3,10 +3,11 @@ Focus Areas Router Manages dynamic focus area definitions and user preferences """ from fastapi import APIRouter, HTTPException, Depends -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional, List from db import get_db, get_cursor, r2d from auth import require_auth +from focus_area_usage_helpers import coerce_usage_type_keys router = APIRouter(prefix="/api/focus-areas", tags=["focus-areas"]) @@ -36,6 +37,11 @@ class UserFocusPreferences(BaseModel): """User's focus area weightings (dynamic)""" preferences: dict # {focus_area_id: weight_pct} + +class FocusAreaUsageTypesUpdate(BaseModel): + """Replace all usage-type assignments for one focus area (admin).""" + usage_type_keys: List[str] = Field(default_factory=list) + # ============================================================================ # Focus Area Definitions (Admin) # ============================================================================ @@ -58,14 +64,27 @@ def list_focus_area_definitions( query = """ SELECT id, key, name_de, name_en, icon, description, category, is_active, - created_at, updated_at + created_at, updated_at, + COALESCE( + ( + SELECT json_agg(faut.key ORDER BY faut.sort_order, faut.key) + FROM focus_area_definition_usage_types fadut + JOIN focus_area_usage_types faut ON faut.id = fadut.usage_type_id + WHERE fadut.focus_area_id = focus_area_definitions.id + ), + '[]'::json + ) AS allowed_usage_type_keys 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()] + areas = [] + for row in cur.fetchall(): + d = r2d(row) + d['allowed_usage_type_keys'] = coerce_usage_type_keys(d.get('allowed_usage_type_keys')) + areas.append(d) # Group by category grouped = {} @@ -75,6 +94,10 @@ def list_focus_area_definitions( grouped[cat] = [] grouped[cat].append(area) + if session.get('role') != 'admin': + for area in areas: + area.pop('allowed_usage_type_keys', None) + return { "areas": areas, "grouped": grouped, @@ -226,6 +249,92 @@ def delete_focus_area_definition( return {"message": "Focus Area gelöscht"} + +@router.get("/usage-types") +def list_focus_area_usage_types(session: dict = Depends(require_auth)): + """ + Liste aller systemdefinierten Nutzungstypen (Admin, Konfigurations-UI). + Keine freie Anlage neuer Typen über die API. + """ + if session.get('role') != 'admin': + raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT id, key, label_de, sort_order + FROM focus_area_usage_types + ORDER BY sort_order, key + """) + rows = [r2d(r) for r in cur.fetchall()] + return {"usage_types": rows, "total": len(rows)} + + +@router.put("/definitions/{area_id}/usage-types") +def replace_focus_area_usage_types( + area_id: str, + data: FocusAreaUsageTypesUpdate, + session: dict = Depends(require_auth), +): + """ + Ersetzt die Nutzungstyp-Zuweisungen einer Focus Area (Admin). + Leere Liste entfernt alle Zuordnungen. Unbekannte Keys → 400. + """ + if session.get('role') != 'admin': + raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich") + + keys = list(dict.fromkeys(data.usage_type_keys)) # dedupe, preserve order + + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute( + "SELECT id FROM focus_area_definitions WHERE id = %s", + (area_id,), + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Focus Area nicht gefunden") + + if not keys: + cur.execute( + "DELETE FROM focus_area_definition_usage_types WHERE focus_area_id = %s", + (area_id,), + ) + return {"message": "Nutzungstyp-Zuweisungen entfernt", "usage_type_keys": []} + + placeholders = ','.join(['%s'] * len(keys)) + cur.execute( + f""" + SELECT id, key FROM focus_area_usage_types + WHERE key IN ({placeholders}) + """, + keys, + ) + found = {row['key']: row['id'] for row in cur.fetchall()} + missing = [k for k in keys if k not in found] + if missing: + raise HTTPException( + status_code=400, + detail=f"Unbekannte Nutzungstyp-Keys: {', '.join(missing)}", + ) + + cur.execute( + "DELETE FROM focus_area_definition_usage_types WHERE focus_area_id = %s", + (area_id,), + ) + for k in keys: + ut_id = found[k] + cur.execute( + """ + INSERT INTO focus_area_definition_usage_types (focus_area_id, usage_type_id) + VALUES (%s, %s) + ON CONFLICT (focus_area_id, usage_type_id) DO NOTHING + """, + (area_id, ut_id), + ) + + return {"message": "Nutzungstyp-Zuweisungen aktualisiert", "usage_type_keys": keys} + # ============================================================================ # User Focus Preferences # ============================================================================ diff --git a/backend/tests/test_focus_area_usage_types.py b/backend/tests/test_focus_area_usage_types.py new file mode 100644 index 0000000..3340770 --- /dev/null +++ b/backend/tests/test_focus_area_usage_types.py @@ -0,0 +1,127 @@ +""" +Tests: Focus Area Nutzungstypen (Migration 036, Router-Helfer). + +Ohne MITAI_INTEGRATION_DB=1 werden nur SQL-Datei und reine Python-Helfer geprüft. +Mit gesetztem Flag optional Verifikation gegen eine PostgreSQL-Instanz (Migration 036 angewendet). +""" +from __future__ import annotations + +import os +import sys +import uuid +from pathlib import Path + +import pytest + +BACKEND_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(BACKEND_ROOT)) + + +def test_migration_036_defines_schema_and_seeds_keys(): + p = BACKEND_ROOT / "migrations" / "036_focus_area_usage_types.sql" + assert p.is_file(), f"expected {p}" + text = p.read_text(encoding="utf-8") + assert "CREATE TABLE IF NOT EXISTS focus_area_usage_types" in text + assert "CREATE TABLE IF NOT EXISTS focus_area_definition_usage_types" in text + for key in ( + "goal_priority", + "expected_training_effect", + "concrete_training_contribution", + ): + assert key in text + assert "ON CONFLICT (key) DO NOTHING" in text + # Explizit: kein automatisches Befüllen der M:N-Tabelle + assert "INSERT INTO focus_area_definition_usage_types" not in text + + +def test_coerce_usage_type_keys_normalizes_values(): + from focus_area_usage_helpers import coerce_usage_type_keys + + assert coerce_usage_type_keys(None) == [] + assert coerce_usage_type_keys([]) == [] + assert coerce_usage_type_keys(["goal_priority", "expected_training_effect"]) == [ + "goal_priority", + "expected_training_effect", + ] + assert coerce_usage_type_keys('["concrete_training_contribution"]') == [ + "concrete_training_contribution" + ] + + +@pytest.mark.skipif( + os.getenv("MITAI_INTEGRATION_DB") != "1", + reason="Set MITAI_INTEGRATION_DB=1 plus DB_* env to run DB checks (nur Dev/CI!)", +) +def test_integration_focus_area_usage_types_seeded_and_junction_writable(): + """Nur gegen Dev-DB ausführen. Nutzt temporäre focus_area_definitions-Zeile, keine bestehenden Daten.""" + from db import get_db, get_cursor + from focus_area_usage_helpers import coerce_usage_type_keys + + tmp_id = str(uuid.uuid4()) + tmp_key = f"tmp_usage_{tmp_id[:8]}" + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT key FROM focus_area_usage_types + ORDER BY sort_order, key + """ + ) + keys = [row["key"] for row in cur.fetchall()] + assert keys == [ + "goal_priority", + "expected_training_effect", + "concrete_training_contribution", + ] + + cur.execute( + """ + INSERT INTO focus_area_definitions + (id, key, name_de, category, is_active) + VALUES (%s, %s, 'tmp_test_usage', 'custom', false) + """, + (tmp_id, tmp_key), + ) + + cur.execute( + """ + SELECT COALESCE( + ( + SELECT json_agg(faut.key ORDER BY faut.sort_order, faut.key) + FROM focus_area_definition_usage_types fadut + JOIN focus_area_usage_types faut ON faut.id = fadut.usage_type_id + WHERE fadut.focus_area_id = %s + ), + '[]'::json + ) AS allowed_usage_type_keys + """, + (tmp_id,), + ) + assert coerce_usage_type_keys(cur.fetchone()["allowed_usage_type_keys"]) == [] + + cur.execute( + """ + INSERT INTO focus_area_definition_usage_types (focus_area_id, usage_type_id) + SELECT %s, u.id FROM focus_area_usage_types u WHERE u.key = %s + """, + (tmp_id, "goal_priority"), + ) + cur.execute( + """ + INSERT INTO focus_area_definition_usage_types (focus_area_id, usage_type_id) + SELECT %s, u.id FROM focus_area_usage_types u WHERE u.key = %s + """, + (tmp_id, "expected_training_effect"), + ) + cur.execute( + """ + SELECT COUNT(*) AS n + FROM focus_area_definition_usage_types + WHERE focus_area_id = %s + """, + (tmp_id,), + ) + assert cur.fetchone()["n"] == 2 + + cur.execute("DELETE FROM focus_area_definitions WHERE id = %s", (tmp_id,)) diff --git a/frontend/src/pages/AdminFocusAreasPage.jsx b/frontend/src/pages/AdminFocusAreasPage.jsx index f28df7e..a68c462 100644 --- a/frontend/src/pages/AdminFocusAreasPage.jsx +++ b/frontend/src/pages/AdminFocusAreasPage.jsx @@ -14,8 +14,19 @@ const CATEGORIES = [ { value: 'custom', label: 'Eigene' } ] +function groupAreasByCategory(areas) { + const grouped = {} + for (const area of areas) { + const cat = area.category || 'other' + if (!grouped[cat]) grouped[cat] = [] + grouped[cat].push(area) + } + return grouped +} + export default function AdminFocusAreasPage() { const [data, setData] = useState({ areas: [], grouped: {}, total: 0 }) + const [usageTypesCatalog, setUsageTypesCatalog] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [showInactive, setShowInactive] = useState(false) @@ -34,11 +45,35 @@ export default function AdminFocusAreasPage() { loadData() }, [showInactive]) + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const r = await api.listFocusAreaUsageTypes() + if (!cancelled) setUsageTypesCatalog(r.usage_types || []) + } catch (e) { + console.error('usage-types catalog:', e) + if (!cancelled) setUsageTypesCatalog([]) + } + })() + return () => { cancelled = true } + }, []) + const loadData = async () => { try { setLoading(true) const result = await api.listFocusAreaDefinitions(showInactive) - setData(result) + const areas = (result.areas || []).map(a => ({ + ...a, + allowed_usage_type_keys: Array.isArray(a.allowed_usage_type_keys) + ? a.allowed_usage_type_keys + : [] + })) + setData({ + areas, + grouped: groupAreasByCategory(areas), + total: result.total ?? areas.length + }) setError(null) } catch (err) { console.error('Failed to load focus areas:', err) @@ -74,14 +109,20 @@ export default function AdminFocusAreasPage() { const handleUpdate = async (id) => { try { const area = data.areas.find(a => a.id === id) - await api.updateFocusAreaDefinition(id, { - name_de: area.name_de, - name_en: area.name_en, - icon: area.icon, - description: area.description, - category: area.category, - is_active: area.is_active - }) + const usageKeys = Array.isArray(area.allowed_usage_type_keys) + ? area.allowed_usage_type_keys + : [] + await Promise.all([ + api.updateFocusAreaDefinition(id, { + name_de: area.name_de, + name_en: area.name_en, + icon: area.icon, + description: area.description, + category: area.category, + is_active: area.is_active + }), + api.setFocusAreaUsageTypes(id, usageKeys) + ]) setEditingId(null) await loadData() } catch (err) { @@ -113,12 +154,25 @@ export default function AdminFocusAreasPage() { } const updateField = (id, field, value) => { - setData(prev => ({ - ...prev, - areas: prev.areas.map(a => + setData(prev => { + const areas = prev.areas.map(a => a.id === id ? { ...a, [field]: value } : a ) - })) + return { ...prev, areas, grouped: groupAreasByCategory(areas), total: areas.length } + }) + } + + const toggleUsageTypeKey = (areaId, key, checked) => { + setData(prev => { + const areas = prev.areas.map(a => { + if (a.id !== areaId) return a + const cur = new Set(Array.isArray(a.allowed_usage_type_keys) ? a.allowed_usage_type_keys : []) + if (checked) cur.add(key) + else cur.delete(key) + return { ...a, allowed_usage_type_keys: [...cur] } + }) + return { ...prev, areas, grouped: groupAreasByCategory(areas), total: areas.length } + }) } if (loading) { @@ -364,6 +418,45 @@ export default function AdminFocusAreasPage() { /> +