""" 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_current_snapshot, get_profile_reference_values_recent_snapshot, 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/snapshot-current") def profile_reference_values_snapshot_current( x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth), ): """ Layer 1: Alle aktuellen Referenzwerte (jüngster Eintrag pro Typ), wie Platzhalter ``{{reference_values_current_json}}``. """ pid = get_pid(x_profile_id) return get_profile_reference_values_current_snapshot(pid) @router.get("/profile-reference-values/snapshot-recent") def profile_reference_values_snapshot_recent( limit_per_type: int = Query(5, ge=1, le=50, description="Einträge pro Referenztyp (neueste zuerst)"), date_from: Optional[str] = Query(None, description="Filter effective_date >= (YYYY-MM-DD)"), date_to: Optional[str] = Query(None, description="Filter effective_date <= (YYYY-MM-DD)"), x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth), ): """ Layer 1: Bis zu N Einträge pro Typ (Verlauf), wie Platzhalter ``{{reference_values_recent_json}}``. """ pid = get_pid(x_profile_id) df = date_from dto = date_to for label, raw in (("date_from", df), ("date_to", dto)): if raw is None or not str(raw).strip(): continue try: datetime.strptime(str(raw).strip(), "%Y-%m-%d") except ValueError: raise HTTPException(400, f"{label}: Ungültiges Datum, Format YYYY-MM-DD") return get_profile_reference_values_recent_snapshot( pid, limit_per_type=limit_per_type, date_from=df, date_to=dto, ) @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}