Migration 014: - blood_pressure_systolic/diastolic (mmHg) - pulse (bpm) - during BP measurement - vo2_max (ml/kg/min) - from Apple Watch - spo2 (%) - blood oxygen saturation - respiratory_rate (breaths/min) - irregular_heartbeat, possible_afib (boolean flags from Omron) - Added 'omron' to source enum Backend: - Updated Pydantic models (VitalsEntry, VitalsUpdate) - Updated all SELECT queries to include new fields - Updated INSERT/UPDATE with COALESCE for partial updates - Validation: at least one vital must be provided Preparation for Omron + Apple Health imports Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
396 lines
14 KiB
Python
396 lines
14 KiB
Python
"""
|
|
Vitals Router - Resting HR + HRV Tracking
|
|
v9d Phase 2: Vitals Module
|
|
|
|
Endpoints:
|
|
- GET /api/vitals List vitals (with limit)
|
|
- GET /api/vitals/by-date/{date} Get vitals for specific date
|
|
- POST /api/vitals Create/update vitals (upsert)
|
|
- PUT /api/vitals/{id} Update vitals
|
|
- DELETE /api/vitals/{id} Delete vitals
|
|
- GET /api/vitals/stats Get vitals statistics
|
|
"""
|
|
from fastapi import APIRouter, HTTPException, Depends, Header
|
|
from pydantic import BaseModel
|
|
from typing import Optional
|
|
from datetime import datetime, timedelta
|
|
import logging
|
|
|
|
from db import get_db, get_cursor, r2d
|
|
from auth import require_auth
|
|
|
|
router = APIRouter(prefix="/api/vitals", tags=["vitals"])
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class VitalsEntry(BaseModel):
|
|
date: str
|
|
resting_hr: Optional[int] = None
|
|
hrv: Optional[int] = None
|
|
blood_pressure_systolic: Optional[int] = None
|
|
blood_pressure_diastolic: Optional[int] = None
|
|
pulse: Optional[int] = None
|
|
vo2_max: Optional[float] = None
|
|
spo2: Optional[int] = None
|
|
respiratory_rate: Optional[float] = None
|
|
irregular_heartbeat: Optional[bool] = None
|
|
possible_afib: Optional[bool] = None
|
|
note: Optional[str] = None
|
|
|
|
|
|
class VitalsUpdate(BaseModel):
|
|
date: Optional[str] = None
|
|
resting_hr: Optional[int] = None
|
|
hrv: Optional[int] = None
|
|
blood_pressure_systolic: Optional[int] = None
|
|
blood_pressure_diastolic: Optional[int] = None
|
|
pulse: Optional[int] = None
|
|
vo2_max: Optional[float] = None
|
|
spo2: Optional[int] = None
|
|
respiratory_rate: Optional[float] = None
|
|
irregular_heartbeat: Optional[bool] = None
|
|
possible_afib: Optional[bool] = None
|
|
note: Optional[str] = None
|
|
|
|
|
|
def get_pid(x_profile_id: Optional[str], session: dict) -> str:
|
|
"""Extract profile_id from session (never from header for security)."""
|
|
return session['profile_id']
|
|
|
|
|
|
@router.get("")
|
|
def list_vitals(
|
|
limit: int = 90,
|
|
x_profile_id: Optional[str] = Header(default=None),
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""Get vitals entries for current profile."""
|
|
pid = get_pid(x_profile_id, session)
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute(
|
|
"""
|
|
SELECT id, profile_id, date, resting_hr, hrv,
|
|
blood_pressure_systolic, blood_pressure_diastolic, pulse,
|
|
vo2_max, spo2, respiratory_rate,
|
|
irregular_heartbeat, possible_afib,
|
|
note, source, created_at, updated_at
|
|
FROM vitals_log
|
|
WHERE profile_id = %s
|
|
ORDER BY date DESC
|
|
LIMIT %s
|
|
""",
|
|
(pid, limit)
|
|
)
|
|
return [r2d(r) for r in cur.fetchall()]
|
|
|
|
|
|
@router.get("/by-date/{date}")
|
|
def get_vitals_by_date(
|
|
date: str,
|
|
x_profile_id: Optional[str] = Header(default=None),
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""Get vitals entry for a specific date."""
|
|
pid = get_pid(x_profile_id, session)
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute(
|
|
"""
|
|
SELECT id, profile_id, date, resting_hr, hrv,
|
|
blood_pressure_systolic, blood_pressure_diastolic, pulse,
|
|
vo2_max, spo2, respiratory_rate,
|
|
irregular_heartbeat, possible_afib,
|
|
note, source, created_at, updated_at
|
|
FROM vitals_log
|
|
WHERE profile_id = %s AND date = %s
|
|
""",
|
|
(pid, date)
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Keine Vitalwerte für dieses Datum gefunden")
|
|
return r2d(row)
|
|
|
|
|
|
@router.post("")
|
|
def create_vitals(
|
|
entry: VitalsEntry,
|
|
x_profile_id: Optional[str] = Header(default=None),
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""Create or update vitals entry (upsert)."""
|
|
pid = get_pid(x_profile_id, session)
|
|
|
|
# Validation: at least one vital must be provided
|
|
has_data = any([
|
|
entry.resting_hr, entry.hrv, entry.blood_pressure_systolic,
|
|
entry.blood_pressure_diastolic, entry.vo2_max, entry.spo2,
|
|
entry.respiratory_rate
|
|
])
|
|
if not has_data:
|
|
raise HTTPException(400, "Mindestens ein Vitalwert muss angegeben werden")
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
# Upsert: insert or update if date already exists
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO vitals_log (
|
|
profile_id, date, resting_hr, hrv,
|
|
blood_pressure_systolic, blood_pressure_diastolic, pulse,
|
|
vo2_max, spo2, respiratory_rate,
|
|
irregular_heartbeat, possible_afib,
|
|
note, source
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'manual')
|
|
ON CONFLICT (profile_id, date)
|
|
DO UPDATE SET
|
|
resting_hr = COALESCE(EXCLUDED.resting_hr, vitals_log.resting_hr),
|
|
hrv = COALESCE(EXCLUDED.hrv, vitals_log.hrv),
|
|
blood_pressure_systolic = COALESCE(EXCLUDED.blood_pressure_systolic, vitals_log.blood_pressure_systolic),
|
|
blood_pressure_diastolic = COALESCE(EXCLUDED.blood_pressure_diastolic, vitals_log.blood_pressure_diastolic),
|
|
pulse = COALESCE(EXCLUDED.pulse, vitals_log.pulse),
|
|
vo2_max = COALESCE(EXCLUDED.vo2_max, vitals_log.vo2_max),
|
|
spo2 = COALESCE(EXCLUDED.spo2, vitals_log.spo2),
|
|
respiratory_rate = COALESCE(EXCLUDED.respiratory_rate, vitals_log.respiratory_rate),
|
|
irregular_heartbeat = COALESCE(EXCLUDED.irregular_heartbeat, vitals_log.irregular_heartbeat),
|
|
possible_afib = COALESCE(EXCLUDED.possible_afib, vitals_log.possible_afib),
|
|
note = COALESCE(EXCLUDED.note, vitals_log.note),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
RETURNING id, profile_id, date, resting_hr, hrv,
|
|
blood_pressure_systolic, blood_pressure_diastolic, pulse,
|
|
vo2_max, spo2, respiratory_rate,
|
|
irregular_heartbeat, possible_afib,
|
|
note, source, created_at, updated_at
|
|
""",
|
|
(pid, entry.date, entry.resting_hr, entry.hrv,
|
|
entry.blood_pressure_systolic, entry.blood_pressure_diastolic, entry.pulse,
|
|
entry.vo2_max, entry.spo2, entry.respiratory_rate,
|
|
entry.irregular_heartbeat, entry.possible_afib,
|
|
entry.note)
|
|
)
|
|
row = cur.fetchone()
|
|
conn.commit()
|
|
|
|
logger.info(f"[VITALS] Upserted vitals for {pid} on {entry.date}")
|
|
return r2d(row)
|
|
|
|
|
|
@router.put("/{vitals_id}")
|
|
def update_vitals(
|
|
vitals_id: int,
|
|
updates: VitalsUpdate,
|
|
x_profile_id: Optional[str] = Header(default=None),
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""Update existing vitals entry."""
|
|
pid = get_pid(x_profile_id, session)
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
# Check ownership
|
|
cur.execute(
|
|
"SELECT id FROM vitals_log WHERE id = %s AND profile_id = %s",
|
|
(vitals_id, pid)
|
|
)
|
|
if not cur.fetchone():
|
|
raise HTTPException(404, "Eintrag nicht gefunden")
|
|
|
|
# Build update query dynamically
|
|
fields = []
|
|
values = []
|
|
|
|
if updates.date is not None:
|
|
fields.append("date = %s")
|
|
values.append(updates.date)
|
|
if updates.resting_hr is not None:
|
|
fields.append("resting_hr = %s")
|
|
values.append(updates.resting_hr)
|
|
if updates.hrv is not None:
|
|
fields.append("hrv = %s")
|
|
values.append(updates.hrv)
|
|
if updates.blood_pressure_systolic is not None:
|
|
fields.append("blood_pressure_systolic = %s")
|
|
values.append(updates.blood_pressure_systolic)
|
|
if updates.blood_pressure_diastolic is not None:
|
|
fields.append("blood_pressure_diastolic = %s")
|
|
values.append(updates.blood_pressure_diastolic)
|
|
if updates.pulse is not None:
|
|
fields.append("pulse = %s")
|
|
values.append(updates.pulse)
|
|
if updates.vo2_max is not None:
|
|
fields.append("vo2_max = %s")
|
|
values.append(updates.vo2_max)
|
|
if updates.spo2 is not None:
|
|
fields.append("spo2 = %s")
|
|
values.append(updates.spo2)
|
|
if updates.respiratory_rate is not None:
|
|
fields.append("respiratory_rate = %s")
|
|
values.append(updates.respiratory_rate)
|
|
if updates.irregular_heartbeat is not None:
|
|
fields.append("irregular_heartbeat = %s")
|
|
values.append(updates.irregular_heartbeat)
|
|
if updates.possible_afib is not None:
|
|
fields.append("possible_afib = %s")
|
|
values.append(updates.possible_afib)
|
|
if updates.note is not None:
|
|
fields.append("note = %s")
|
|
values.append(updates.note)
|
|
|
|
if not fields:
|
|
raise HTTPException(400, "Keine Änderungen angegeben")
|
|
|
|
fields.append("updated_at = CURRENT_TIMESTAMP")
|
|
values.append(vitals_id)
|
|
|
|
query = f"""
|
|
UPDATE vitals_log
|
|
SET {', '.join(fields)}
|
|
WHERE id = %s
|
|
RETURNING id, profile_id, date, resting_hr, hrv,
|
|
blood_pressure_systolic, blood_pressure_diastolic, pulse,
|
|
vo2_max, spo2, respiratory_rate,
|
|
irregular_heartbeat, possible_afib,
|
|
note, source, created_at, updated_at
|
|
"""
|
|
|
|
cur.execute(query, values)
|
|
row = cur.fetchone()
|
|
conn.commit()
|
|
|
|
return r2d(row)
|
|
|
|
|
|
@router.delete("/{vitals_id}")
|
|
def delete_vitals(
|
|
vitals_id: int,
|
|
x_profile_id: Optional[str] = Header(default=None),
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""Delete vitals entry."""
|
|
pid = get_pid(x_profile_id, session)
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
# Check ownership and delete
|
|
cur.execute(
|
|
"DELETE FROM vitals_log WHERE id = %s AND profile_id = %s RETURNING id",
|
|
(vitals_id, pid)
|
|
)
|
|
if not cur.fetchone():
|
|
raise HTTPException(404, "Eintrag nicht gefunden")
|
|
|
|
conn.commit()
|
|
logger.info(f"[VITALS] Deleted vitals {vitals_id} for {pid}")
|
|
return {"message": "Eintrag gelöscht"}
|
|
|
|
|
|
@router.get("/stats")
|
|
def get_vitals_stats(
|
|
days: int = 30,
|
|
x_profile_id: Optional[str] = Header(default=None),
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""
|
|
Get vitals statistics over the last N days.
|
|
|
|
Returns:
|
|
- avg_resting_hr (7d and 30d)
|
|
- avg_hrv (7d and 30d)
|
|
- trend (increasing/decreasing/stable)
|
|
- latest values
|
|
"""
|
|
pid = get_pid(x_profile_id, session)
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
# Get latest entry
|
|
cur.execute(
|
|
"""
|
|
SELECT date, resting_hr, hrv
|
|
FROM vitals_log
|
|
WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '%s days'
|
|
ORDER BY date DESC
|
|
LIMIT 1
|
|
""",
|
|
(pid, days)
|
|
)
|
|
latest = cur.fetchone()
|
|
|
|
# Get averages (7d and 30d)
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '7 days' THEN resting_hr END) as avg_hr_7d,
|
|
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '30 days' THEN resting_hr END) as avg_hr_30d,
|
|
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '7 days' THEN hrv END) as avg_hrv_7d,
|
|
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '30 days' THEN hrv END) as avg_hrv_30d,
|
|
COUNT(*) as total_entries
|
|
FROM vitals_log
|
|
WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '%s days'
|
|
""",
|
|
(pid, max(days, 30))
|
|
)
|
|
stats_row = cur.fetchone()
|
|
|
|
# Get entries for trend calculation (last 14 days)
|
|
cur.execute(
|
|
"""
|
|
SELECT date, resting_hr, hrv
|
|
FROM vitals_log
|
|
WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '14 days'
|
|
ORDER BY date ASC
|
|
""",
|
|
(pid,)
|
|
)
|
|
entries = [r2d(r) for r in cur.fetchall()]
|
|
|
|
# Simple trend calculation (compare first half vs second half)
|
|
trend_hr = "stable"
|
|
trend_hrv = "stable"
|
|
|
|
if len(entries) >= 4:
|
|
mid = len(entries) // 2
|
|
first_half_hr = [e['resting_hr'] for e in entries[:mid] if e['resting_hr']]
|
|
second_half_hr = [e['resting_hr'] for e in entries[mid:] if e['resting_hr']]
|
|
|
|
if first_half_hr and second_half_hr:
|
|
avg_first = sum(first_half_hr) / len(first_half_hr)
|
|
avg_second = sum(second_half_hr) / len(second_half_hr)
|
|
diff = avg_second - avg_first
|
|
|
|
if diff > 2:
|
|
trend_hr = "increasing"
|
|
elif diff < -2:
|
|
trend_hr = "decreasing"
|
|
|
|
first_half_hrv = [e['hrv'] for e in entries[:mid] if e['hrv']]
|
|
second_half_hrv = [e['hrv'] for e in entries[mid:] if e['hrv']]
|
|
|
|
if first_half_hrv and second_half_hrv:
|
|
avg_first_hrv = sum(first_half_hrv) / len(first_half_hrv)
|
|
avg_second_hrv = sum(second_half_hrv) / len(second_half_hrv)
|
|
diff_hrv = avg_second_hrv - avg_first_hrv
|
|
|
|
if diff_hrv > 5:
|
|
trend_hrv = "increasing"
|
|
elif diff_hrv < -5:
|
|
trend_hrv = "decreasing"
|
|
|
|
return {
|
|
"latest": r2d(latest) if latest else None,
|
|
"avg_resting_hr_7d": round(stats_row['avg_hr_7d'], 1) if stats_row['avg_hr_7d'] else None,
|
|
"avg_resting_hr_30d": round(stats_row['avg_hr_30d'], 1) if stats_row['avg_hr_30d'] else None,
|
|
"avg_hrv_7d": round(stats_row['avg_hrv_7d'], 1) if stats_row['avg_hrv_7d'] else None,
|
|
"avg_hrv_30d": round(stats_row['avg_hrv_30d'], 1) if stats_row['avg_hrv_30d'] else None,
|
|
"total_entries": stats_row['total_entries'],
|
|
"trend_resting_hr": trend_hr,
|
|
"trend_hrv": trend_hrv,
|
|
"period_days": days
|
|
}
|