feat: Introduce admin reference value types management in API and UI
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- Added new routes and API endpoints for managing reference value types in the admin section.
- Updated the frontend to include navigation and components for reference value types management.
- Enhanced the backend to support the new reference value types in the data layer and versioning.
This commit is contained in:
Lars 2026-04-06 19:51:23 +02:00
parent f0e6fd04fb
commit ab616ba044
7 changed files with 570 additions and 1 deletions

View File

@ -29,6 +29,7 @@ from routers import charts # Phase 0c Multi-Layer Architecture
from routers import workflow_questions # Phase 1 Workflow Engine - Question Catalog
from routers import workflows # Phase 2 Workflow Engine - Execution
from routers import reference_values # Persönliche Referenzwerte (Profil)
from routers import admin_reference_value_types # Admin: Referenzwert-Typen
# ── App Configuration ─────────────────────────────────────────────────────────
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
@ -117,6 +118,7 @@ app.include_router(charts.router) # /api/charts/* (Phase 0c Charts
app.include_router(workflow_questions.router) # /api/workflow/questions/* (Phase 1 Question Catalog)
app.include_router(workflows.router) # /api/workflows/* (Phase 2 Execution)
app.include_router(reference_values.router) # /api/reference-value-types, /api/profile-reference-values
app.include_router(admin_reference_value_types.router) # /api/admin/reference-value-types
# ── Health Check ──────────────────────────────────────────────────────────────
@app.get("/")

View File

@ -0,0 +1,192 @@
"""
Admin: Referenzwert-Typen (Katalog für persönliche Referenzwerte).
Nur Admins; Nutzer sehen nur aktive Typen über /api/reference-value-types.
"""
import re
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from psycopg2 import errors as pg_errors
from psycopg2.extras import Json
from auth import require_admin
from db import get_db, get_cursor, r2d
router = APIRouter(prefix="/api/admin/reference-value-types", tags=["admin", "reference-value-types"])
KEY_PATTERN = re.compile(r"^[a-z][a-z0-9_]{0,62}$")
def _serialize_type(row: dict[str, Any]) -> dict[str, Any]:
if not row:
return row
out = dict(row)
ca = out.get("created_at")
if ca is not None and hasattr(ca, "isoformat"):
out["created_at"] = ca.isoformat()
return out
class ReferenceValueTypeAdminCreate(BaseModel):
key: str = Field(..., min_length=1, max_length=64)
label: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = None
default_unit: Optional[str] = Field(None, max_length=32)
sort_order: int = 0
active: bool = True
metadata: Optional[dict] = None
class ReferenceValueTypeAdminUpdate(BaseModel):
label: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
default_unit: Optional[str] = Field(None, max_length=32)
sort_order: Optional[int] = None
active: Optional[bool] = None
metadata: Optional[dict] = None
def _normalize_key(key: str) -> str:
k = key.strip().lower()
if not KEY_PATTERN.match(k):
raise HTTPException(
400,
"Ungültiger Schlüssel: nur Kleinbuchstaben, Ziffern, Unterstriche; muss mit Buchstabe beginnen.",
)
return k
def _unit_or_none(u: Optional[str]) -> Optional[str]:
if u is None:
return None
s = u.strip()
return s if s else None
@router.get("")
def admin_list_reference_value_types(session: dict = Depends(require_admin)):
"""Alle Typen inkl. inaktiver (Admin-Übersicht)."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, key, label, description, default_unit, sort_order, active, metadata, created_at
FROM reference_value_types
ORDER BY sort_order ASC, id ASC
"""
)
return [_serialize_type(r2d(r)) for r in cur.fetchall()]
@router.get("/{type_id}")
def admin_get_reference_value_type(type_id: int, session: dict = Depends(require_admin)):
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, key, label, description, default_unit, sort_order, active, metadata, created_at
FROM reference_value_types WHERE id = %s
""",
(type_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "Typ nicht gefunden")
return _serialize_type(r2d(row))
@router.post("")
def admin_create_reference_value_type(
body: ReferenceValueTypeAdminCreate,
session: dict = Depends(require_admin),
):
key = _normalize_key(body.key)
meta = body.metadata if body.metadata is not None else {}
du = _unit_or_none(body.default_unit)
with get_db() as conn:
cur = get_cursor(conn)
try:
cur.execute(
"""
INSERT INTO reference_value_types
(key, label, description, default_unit, sort_order, active, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id, key, label, description, default_unit, sort_order, active, metadata, created_at
""",
(
key,
body.label.strip(),
body.description.strip() if body.description else None,
du,
body.sort_order,
body.active,
Json(meta),
),
)
return _serialize_type(r2d(cur.fetchone()))
except pg_errors.UniqueViolation:
raise HTTPException(409, "Ein Typ mit diesem Schlüssel existiert bereits.")
@router.put("/{type_id}")
def admin_update_reference_value_type(
type_id: int,
body: ReferenceValueTypeAdminUpdate,
session: dict = Depends(require_admin),
):
patch = body.model_dump(exclude_unset=True)
if not patch:
raise HTTPException(400, "Keine Felder zum Aktualisieren")
if "default_unit" in patch:
patch["default_unit"] = _unit_or_none(patch.get("default_unit"))
if "description" in patch and patch["description"] is not None:
patch["description"] = patch["description"].strip() or None
if "metadata" in patch:
patch["metadata"] = Json(patch["metadata"] if patch["metadata"] is not None else {})
cols = []
vals = []
for k, v in patch.items():
cols.append(f"{k} = %s")
vals.append(v)
vals.append(type_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
f"""
UPDATE reference_value_types SET {", ".join(cols)}
WHERE id = %s
RETURNING id, key, label, description, default_unit, sort_order, active, metadata, created_at
""",
tuple(vals),
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "Typ nicht gefunden")
return _serialize_type(r2d(row))
@router.delete("/{type_id}")
def admin_delete_reference_value_type(type_id: int, session: dict = Depends(require_admin)):
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT COUNT(*) AS c FROM profile_reference_values WHERE reference_value_type_id = %s",
(type_id,),
)
n = cur.fetchone()["c"]
if n > 0:
raise HTTPException(
409,
f"Es gibt noch {n} gespeicherte Referenzwert(e) zu diesem Typ. "
"Bitte zuerst löschen oder den Typ deaktivieren (active = aus).",
)
cur.execute("DELETE FROM reference_value_types WHERE id = %s RETURNING id", (type_id,))
if not cur.fetchone():
raise HTTPException(404, "Typ nicht gefunden")
return {"ok": True}

View File

@ -14,7 +14,8 @@ DB_SCHEMA_VERSION = "20260406" # Migration 037
MODULE_VERSIONS = {
"auth": "1.2.0",
"profiles": "1.1.0",
"reference_values": "1.0.0",
"reference_values": "1.1.0",
"admin_reference_value_types": "1.0.0",
"weight": "1.0.3",
"circumference": "1.0.1",
"caliper": "1.0.1",

View File

@ -35,6 +35,7 @@ import AdminTrainingProfiles from './pages/AdminTrainingProfiles'
import AdminPromptsPage from './pages/AdminPromptsPage'
import AdminGoalTypesPage from './pages/AdminGoalTypesPage'
import AdminFocusAreasPage from './pages/AdminFocusAreasPage'
import AdminReferenceValueTypesPage from './pages/AdminReferenceValueTypesPage'
import AdminHomePage from './pages/AdminHomePage'
import AdminUsersPage from './pages/AdminUsersPage'
import AdminSystemPage from './pages/AdminSystemPage'
@ -244,6 +245,7 @@ function AppShell() {
<Route path="prompts" element={<AdminPromptsPage/>}/>
<Route path="goal-types" element={<AdminGoalTypesPage/>}/>
<Route path="focus-areas" element={<AdminFocusAreasPage/>}/>
<Route path="reference-value-types" element={<AdminReferenceValueTypesPage/>}/>
</Route>
</Route>
<Route path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/>

View File

@ -86,6 +86,11 @@ export const ADMIN_GROUPS = [
label: 'Focus Areas',
description: 'Dynamische Fokusbereiche und Kategorien.',
},
{
to: '/admin/reference-value-types',
label: 'Referenz-Kennwerte',
description: 'Typen für persönliche Referenzwerte (Schlüssel, Namen, Metadaten).',
},
],
},
{

View File

@ -0,0 +1,361 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { Gauge, Plus, Pencil, Trash2, Save, X } from 'lucide-react'
import { api } from '../utils/api'
const emptyForm = () => ({
key: '',
label: '',
description: '',
default_unit: '',
sort_order: 0,
active: true,
metadata_json: '{}',
})
export default function AdminReferenceValueTypesPage() {
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [toast, setToast] = useState(null)
const [editingId, setEditingId] = useState(null)
const [form, setForm] = useState(emptyForm())
const [showForm, setShowForm] = useState(false)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await api.adminListReferenceValueTypes()
setRows(Array.isArray(data) ? data : [])
} catch (e) {
setError(e.message || 'Laden fehlgeschlagen')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
const showToast = (msg) => {
setToast(msg)
setTimeout(() => setToast(null), 2500)
}
const openCreate = () => {
setEditingId(null)
setForm(emptyForm())
setShowForm(true)
}
const openEdit = (r) => {
setEditingId(r.id)
setForm({
key: r.key || '',
label: r.label || '',
description: r.description || '',
default_unit: r.default_unit || '',
sort_order: r.sort_order ?? 0,
active: !!r.active,
metadata_json: r.metadata && typeof r.metadata === 'object'
? JSON.stringify(r.metadata, null, 2)
: '{}',
})
setShowForm(true)
}
const closeForm = () => {
setShowForm(false)
setEditingId(null)
setForm(emptyForm())
}
const handleSave = async () => {
if (!form.label.trim()) {
setError('Bitte einen Anzeigenamen (label) angeben.')
return
}
let metadata = {}
try {
const mj = form.metadata_json.trim() || '{}'
metadata = JSON.parse(mj)
if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) {
setError('Metadaten müssen ein JSON-Objekt sein.')
return
}
} catch {
setError('Metadaten: ungültiges JSON.')
return
}
const payload = {
label: form.label.trim(),
description: form.description.trim() || null,
default_unit: form.default_unit.trim() || null,
sort_order: Number(form.sort_order) || 0,
active: !!form.active,
metadata,
}
try {
setError(null)
if (editingId) {
await api.adminUpdateReferenceValueType(editingId, payload)
showToast('Typ gespeichert')
} else {
if (!form.key.trim()) {
setError('Bitte einen technischen Schlüssel (key) angeben.')
return
}
await api.adminCreateReferenceValueType({
key: form.key.trim().toLowerCase(),
...payload,
})
showToast('Typ angelegt')
}
closeForm()
await load()
} catch (e) {
setError(e.message || 'Speichern fehlgeschlagen')
}
}
const handleDelete = async (r) => {
if (
!confirm(
`Typ „${r.label}“ (${r.key}) wirklich löschen? Nur möglich, wenn keine Nutzer-Einträge existieren.`,
)
) {
return
}
try {
setError(null)
await api.adminDeleteReferenceValueType(r.id)
showToast('Typ gelöscht')
await load()
} catch (e) {
setError(e.message || 'Löschen fehlgeschlagen')
}
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 48 }}>
<div className="spinner" />
</div>
)
}
return (
<div className="page">
<div className="page-header" style={{ marginBottom: 16 }}>
<h1 style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 22, margin: 0 }}>
<Gauge size={26} color="var(--accent)" />
Referenz-Kennwerte (Typen)
</h1>
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8, lineHeight: 1.6 }}>
Hier definierst du die verfügbaren Kennwert-Typen für Nutzer (Einstellungen Referenzwerte).
Schlüssel sind nach Anlage fest; Nutzer können nur Werte zu diesen Typen erfassen.
</p>
<div style={{ marginTop: 12, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<Link to="/admin/g/goals" className="btn btn-secondary" style={{ fontSize: 13 }}>
Zur Gruppe Ziele & Fokus
</Link>
<button type="button" className="btn btn-primary" onClick={openCreate}>
<Plus size={16} style={{ marginRight: 6 }} />
Neuer Typ
</button>
</div>
</div>
{toast && (
<div
style={{
marginBottom: 12,
padding: '10px 14px',
background: 'var(--accent-light)',
color: 'var(--accent-dark)',
borderRadius: 8,
fontSize: 14,
}}
>
{toast}
</div>
)}
{error && (
<div className="card" style={{ background: '#FEF2F2', border: '1px solid #FCA5A5', marginBottom: 16 }}>
<p style={{ color: '#DC2626', margin: 0, fontSize: 14 }}>{error}</p>
</div>
)}
{showForm && (
<div className="card section-gap" style={{ marginBottom: 16 }}>
<div className="card-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{editingId ? 'Typ bearbeiten' : 'Neuer Typ'}
<button type="button" className="btn btn-secondary" onClick={closeForm} style={{ padding: '6px 10px' }}>
<X size={16} />
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label className="form-label" htmlFor="ref-admin-key">
Technischer Schlüssel (key)
</label>
<input
id="ref-admin-key"
className="form-input"
disabled={!!editingId}
value={form.key}
onChange={(e) => setForm((f) => ({ ...f, key: e.target.value }))}
placeholder="z. B. max_heart_rate"
autoComplete="off"
/>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '6px 0 0' }}>
Kleinbuchstaben, Ziffern, Unterstriche; nach Anlage nicht änderbar.
</p>
</div>
<div>
<label className="form-label" htmlFor="ref-admin-label">
Anzeigename
</label>
<input
id="ref-admin-label"
className="form-input"
value={form.label}
onChange={(e) => setForm((f) => ({ ...f, label: e.target.value }))}
placeholder="z. B. Maximale Herzfrequenz"
/>
</div>
<div>
<label className="form-label" htmlFor="ref-admin-desc">
Beschreibung
</label>
<textarea
id="ref-admin-desc"
className="form-input"
rows={3}
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="Kurze Erklärung für Nutzer und Admins"
/>
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 160px' }}>
<label className="form-label" htmlFor="ref-admin-unit">
Standard-Einheit
</label>
<input
id="ref-admin-unit"
className="form-input"
value={form.default_unit}
onChange={(e) => setForm((f) => ({ ...f, default_unit: e.target.value }))}
placeholder="bpm, Stufe, …"
/>
</div>
<div style={{ flex: '0 0 120px' }}>
<label className="form-label" htmlFor="ref-admin-sort">
Sortierung
</label>
<input
id="ref-admin-sort"
type="number"
className="form-input"
value={form.sort_order}
onChange={(e) => setForm((f) => ({ ...f, sort_order: e.target.value }))}
/>
</div>
<div style={{ flex: '0 0 140px', alignSelf: 'flex-end', paddingBottom: 4 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 14 }}>
<input
type="checkbox"
checked={form.active}
onChange={(e) => setForm((f) => ({ ...f, active: e.target.checked }))}
/>
Aktiv (nutzer sichtbar)
</label>
</div>
</div>
<div>
<label className="form-label" htmlFor="ref-admin-meta">
Metadaten (JSON-Objekt)
</label>
<textarea
id="ref-admin-meta"
className="form-input"
rows={5}
value={form.metadata_json}
onChange={(e) => setForm((f) => ({ ...f, metadata_json: e.target.value }))}
placeholder='{}'
spellCheck={false}
/>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn btn-primary" onClick={handleSave}>
<Save size={16} style={{ marginRight: 6 }} />
Speichern
</button>
<button type="button" className="btn btn-secondary" onClick={closeForm}>
Abbrechen
</button>
</div>
</div>
</div>
)}
<div className="card">
<div className="card-title">Alle Typen ({rows.length})</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)', color: 'var(--text2)' }}>
<th style={{ padding: '8px 6px' }}>Key</th>
<th style={{ padding: '8px 6px' }}>Name</th>
<th style={{ padding: '8px 6px' }}>Einheit</th>
<th style={{ padding: '8px 6px' }}>Sort.</th>
<th style={{ padding: '8px 6px' }}>Aktiv</th>
<th style={{ padding: '8px 6px' }} />
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '10px 6px', fontFamily: 'monospace', fontSize: 13 }}>{r.key}</td>
<td style={{ padding: '10px 6px' }}>{r.label}</td>
<td style={{ padding: '10px 6px', color: 'var(--text2)' }}>{r.default_unit || ''}</td>
<td style={{ padding: '10px 6px' }}>{r.sort_order}</td>
<td style={{ padding: '10px 6px' }}>{r.active ? '✓' : ''}</td>
<td style={{ padding: '6px', textAlign: 'right', whiteSpace: 'nowrap' }}>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '6px 10px', marginRight: 6 }}
onClick={() => openEdit(r)}
title="Bearbeiten"
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '6px 10px', color: 'var(--danger)' }}
onClick={() => handleDelete(r)}
title="Löschen"
>
<Trash2 size={14} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{rows.length === 0 && (
<p style={{ color: 'var(--text2)', margin: 0, fontSize: 14 }}>Noch keine Typen Neuer Typ anlegen.</p>
)}
</div>
</div>
)
}

View File

@ -48,6 +48,12 @@ export const api = {
updateProfileReferenceValue: (id, d) => req(`/profile-reference-values/${id}`, jput(d)),
deleteProfileReferenceValue: (id) => req(`/profile-reference-values/${id}`, { method: 'DELETE' }),
// Admin: Referenzwert-Typen (Katalog)
adminListReferenceValueTypes: () => req('/admin/reference-value-types'),
adminCreateReferenceValueType: (d) => req('/admin/reference-value-types', json(d)),
adminUpdateReferenceValueType: (id, d) => req(`/admin/reference-value-types/${id}`, jput(d)),
adminDeleteReferenceValueType: (id) => req(`/admin/reference-value-types/${id}`, { method: 'DELETE' }),
// Weight
listWeight: (l=365) => req(`/weight?limit=${l}`),
upsertWeight: (date,weight,note='') => req('/weight',json({date,weight,note})),