mitai-jinkendo/backend/routers/reference_values.py
Lars 932bceb1e1
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 23s
feat: Update reference values and introduce pilot visualization module
- Bumped version of reference_values module to 1.3.0.
- Added new imports and functionality for reference values in the backend, enhancing data retrieval.
- Introduced a new PilotVizPage in the frontend for visualizing data, linked from the SettingsPage for easy access.
- Updated routing in App.jsx to include the new pilot visualization route.
2026-04-07 10:15:13 +02:00

321 lines
11 KiB
Python

"""
Persönliche Referenzwerte (profilorientiert)
Typkatalog system-seeded; Nutzer pflegt historische Einträge pro aktivem Profil.
Einheit immer aus dem Typ; Wert je value_data_type validiert.
Reads (Liste, Summary, Katalog) → data_layer.reference_values (Layer 1).
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any, Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from pydantic import BaseModel, Field
from psycopg2.extras import Json
from auth import require_auth
from data_layer.reference_values import (
fetch_reference_type_by_key,
get_profile_reference_values_summary,
list_active_reference_value_types_data,
list_profile_reference_values_for_type,
normalize_reference_row,
)
from db import get_db, get_cursor, r2d
from reference_value_validation import (
REF_VALUE_CONFIDENCE,
REF_VALUE_CONFIDENCE_ORDER,
REF_VALUE_METHODS,
REF_VALUE_SOURCES,
validate_meta_confidence,
validate_meta_method,
validate_meta_source,
validate_value_for_data_type,
resolve_unit_from_type,
)
from routers.profiles import get_pid
router = APIRouter(prefix="/api", tags=["reference-values"])
class ProfileReferenceValueCreate(BaseModel):
reference_value_type_key: str = Field(..., min_length=1, max_length=64)
effective_date: str
value_numeric: Optional[float] = None
value_text: Optional[str] = None
source: str = Field(..., min_length=1)
method: str = Field(..., min_length=1)
confidence: str = Field(..., min_length=1)
notes: Optional[str] = None
extra: Optional[dict] = None
class ProfileReferenceValueUpdate(BaseModel):
effective_date: Optional[str] = None
value_numeric: Optional[float] = None
value_text: Optional[str] = None
source: Optional[str] = None
method: Optional[str] = None
confidence: Optional[str] = None
notes: Optional[str] = None
extra: Optional[dict] = None
@router.get("/reference-value-types")
def list_reference_value_types(session: dict = Depends(require_auth)):
"""Alle aktiven Referenztyp-Definitionen (dynamische UI inkl. Validierungsmetadaten)."""
return list_active_reference_value_types_data()
@router.get("/reference-value-meta/enums")
def list_reference_value_meta_enums(session: dict = Depends(require_auth)):
"""Erlaubte Werte für Quelle, Methode, Vertrauensgrad (Erfassungsdialog)."""
return {
"sources": sorted(REF_VALUE_SOURCES),
"methods": sorted(REF_VALUE_METHODS),
"confidence_levels": [x for x in REF_VALUE_CONFIDENCE_ORDER if x in REF_VALUE_CONFIDENCE],
}
@router.get("/profile-reference-values/summary")
def profile_reference_values_summary(
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
"""
Für das aktive Profil: je Referenztyp mit mindestens einem Eintrag der jüngste Wert
plus der unmittelbar vorherige (gleiche Sortierung wie Liste), für Tendenz-Anzeigen.
"""
pid = get_pid(x_profile_id)
return get_profile_reference_values_summary(pid)
@router.get("/profile-reference-values")
def list_profile_reference_values(
type_key: str = Query(..., description="Schlüssel aus reference_value_types.key"),
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
"""Historische Einträge eines Typs für das aktive Profil (neueste zuerst)."""
pid = get_pid(x_profile_id)
rows = list_profile_reference_values_for_type(pid, type_key)
if rows is None:
raise HTTPException(404, "Referenztyp nicht gefunden")
return rows
@router.post("/profile-reference-values")
def create_profile_reference_value(
body: ProfileReferenceValueCreate,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
pid = get_pid(x_profile_id)
try:
datetime.strptime(body.effective_date, "%Y-%m-%d")
except ValueError:
raise HTTPException(400, "Ungültiges Datum. Format: YYYY-MM-DD")
src = validate_meta_source(body.source)
meth = validate_meta_method(body.method)
conf = validate_meta_confidence(body.confidence)
with get_db() as conn:
cur = get_cursor(conn)
t = fetch_reference_type_by_key(cur, body.reference_value_type_key.strip(), require_active=True)
if not t:
raise HTTPException(404, "Referenztyp nicht gefunden")
vdt = (t.get("value_data_type") or "decimal").strip().lower()
rules = t.get("validation_rules") or {}
vnum, vtxt = validate_value_for_data_type(vdt, rules, body.value_numeric, body.value_text)
unit = resolve_unit_from_type(t.get("default_unit"))
extra = body.extra if body.extra is not None else {}
cur.execute(
"""
INSERT INTO profile_reference_values (
profile_id, reference_value_type_id, effective_date,
value_numeric, value_text, unit, source, confidence, method, notes, extra
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
pid,
t["id"],
body.effective_date,
vnum,
vtxt,
unit,
src,
conf,
meth,
body.notes,
Json(extra),
),
)
new_id = cur.fetchone()["id"]
cur.execute(
"""
SELECT
v.id,
v.profile_id,
v.reference_value_type_id,
v.effective_date,
v.value_numeric,
v.value_text,
v.unit,
v.source,
v.confidence,
v.method,
v.notes,
v.extra,
v.created_at,
v.updated_at,
rt.key AS type_key,
rt.label AS type_label
FROM profile_reference_values v
JOIN reference_value_types rt ON rt.id = v.reference_value_type_id
WHERE v.id = %s AND v.profile_id = %s
""",
(new_id, pid),
)
return normalize_reference_row(r2d(cur.fetchone()))
@router.put("/profile-reference-values/{entry_id}")
def update_profile_reference_value(
entry_id: int,
body: ProfileReferenceValueUpdate,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
pid = get_pid(x_profile_id)
patch = body.model_dump(exclude_unset=True)
if not patch:
raise HTTPException(400, "Keine Felder zum Aktualisieren")
if patch.get("effective_date"):
try:
datetime.strptime(patch["effective_date"], "%Y-%m-%d")
except ValueError:
raise HTTPException(400, "Ungültiges Datum. Format: YYYY-MM-DD")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT v.*, rt.default_unit, rt.value_data_type, rt.validation_rules
FROM profile_reference_values v
JOIN reference_value_types rt ON rt.id = v.reference_value_type_id
WHERE v.id = %s AND v.profile_id = %s
""",
(entry_id, pid),
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "Eintrag nicht gefunden")
cur_row = r2d(row)
vdt = (cur_row.get("value_data_type") or "decimal").strip().lower()
rules = cur_row.get("validation_rules") or {}
new_ed = patch.get("effective_date", cur_row["effective_date"])
if hasattr(new_ed, "isoformat"):
new_ed = new_ed.isoformat()
vn = patch["value_numeric"] if "value_numeric" in patch else cur_row.get("value_numeric")
vt_raw = patch["value_text"] if "value_text" in patch else cur_row.get("value_text")
if vn is not None and isinstance(vn, Decimal):
vn = float(vn)
vnum, vtxt = validate_value_for_data_type(vdt, rules, vn, vt_raw)
unit = resolve_unit_from_type(cur_row.get("default_unit"))
if "source" in patch:
src = validate_meta_source(patch["source"])
else:
src = validate_meta_source(cur_row.get("source"))
if "method" in patch:
meth = validate_meta_method(patch["method"])
else:
meth = validate_meta_method(cur_row.get("method"))
if "confidence" in patch:
conf = validate_meta_confidence(patch["confidence"])
else:
conf = validate_meta_confidence(cur_row.get("confidence"))
updates: dict[str, Any] = {
"effective_date": new_ed,
"value_numeric": vnum,
"value_text": vtxt,
"unit": unit,
"source": src,
"method": meth,
"confidence": conf,
}
if "notes" in patch:
updates["notes"] = patch["notes"]
if "extra" in patch:
updates["extra"] = Json(patch["extra"] if patch["extra"] is not None else {})
set_parts = [f"{k} = %s" for k in updates]
vals = list(updates.values()) + [entry_id, pid]
cur.execute(
f"""
UPDATE profile_reference_values SET {", ".join(set_parts)}, updated_at = NOW()
WHERE id = %s AND profile_id = %s
""",
tuple(vals),
)
cur.execute(
"""
SELECT
v.id,
v.profile_id,
v.reference_value_type_id,
v.effective_date,
v.value_numeric,
v.value_text,
v.unit,
v.source,
v.confidence,
v.method,
v.notes,
v.extra,
v.created_at,
v.updated_at,
rt.key AS type_key,
rt.label AS type_label
FROM profile_reference_values v
JOIN reference_value_types rt ON rt.id = v.reference_value_type_id
WHERE v.id = %s AND v.profile_id = %s
""",
(entry_id, pid),
)
return normalize_reference_row(r2d(cur.fetchone()))
@router.delete("/profile-reference-values/{entry_id}")
def delete_profile_reference_value(
entry_id: int,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"DELETE FROM profile_reference_values WHERE id = %s AND profile_id = %s RETURNING id",
(entry_id, pid),
)
if not cur.fetchone():
raise HTTPException(404, "Eintrag nicht gefunden")
return {"ok": True}