feat: Implement focus area usage types management in API and UI
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 15s

- Added endpoints for listing and updating focus area usage types in the backend.
- Enhanced the AdminFocusAreasPage to display and manage allowed usage types for focus areas.
- Introduced a new state for usage types catalog and integrated it into the focus area editing process.
- Updated API utility functions to support new usage types operations.
This commit is contained in:
Lars 2026-04-06 07:28:19 +02:00
parent 49e9c9c214
commit e7dedd527f
6 changed files with 435 additions and 16 deletions

View File

@ -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 []

View File

@ -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;

View File

@ -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
# ============================================================================

View File

@ -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,))

View File

@ -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, {
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() {
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 6 }}>
Erlaubte Nutzungstypen
</label>
{usageTypesCatalog.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
Kein Katalog geladen (Backend / Migration prüfen).
</span>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{usageTypesCatalog.map(ut => (
<label
key={ut.id}
style={{
fontSize: 13,
display: 'flex',
alignItems: 'flex-start',
gap: 8,
cursor: 'pointer'
}}
>
<input
type="checkbox"
style={{ marginTop: 3 }}
checked={(area.allowed_usage_type_keys || []).includes(ut.key)}
onChange={(e) =>
toggleUsageTypeKey(area.id, ut.key, e.target.checked)
}
/>
<span>
<span style={{ display: 'block' }}>{ut.label_de}</span>
<code style={{ fontSize: 11, color: 'var(--text3)' }}>{ut.key}</code>
</span>
</label>
))}
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn-primary"
@ -430,6 +523,37 @@ export default function AdminFocusAreasPage() {
{area.description}
</div>
)}
{(area.allowed_usage_type_keys || []).length > 0 && (
<div
style={{
fontSize: 12,
marginTop: 8,
display: 'flex',
flexWrap: 'wrap',
gap: 6
}}
>
{(area.allowed_usage_type_keys || []).map(k => {
const ut = usageTypesCatalog.find(x => x.key === k)
return (
<span
key={k}
style={{
padding: '2px 8px',
borderRadius: 6,
background: 'var(--surface2)',
border: '1px solid var(--border)',
color: 'var(--text2)'
}}
title={k}
>
{ut?.label_de || k}
</span>
)
})}
</div>
)}
</div>
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>

View File

@ -375,6 +375,9 @@ export const api = {
getUserFocusPreferences: () => req('/focus-areas/user-preferences'),
updateUserFocusPreferences: (d) => req('/focus-areas/user-preferences', jput(d)),
getFocusAreaStats: () => req('/focus-areas/stats'),
listFocusAreaUsageTypes: () => req('/focus-areas/usage-types'),
setFocusAreaUsageTypes: (id, usageTypeKeys) =>
req(`/focus-areas/definitions/${id}/usage-types`, jput({ usage_type_keys: usageTypeKeys })),
// Chart Endpoints (Phase 0c - Phase 1: Nutrition + Recovery)
// Nutrition Charts (E1-E5)