- Import endpoints for Omron blood pressure CSV (German date format)
- Import endpoints for Apple Health vitals CSV
- Import UI tab in VitalsPage with drag & drop for both sources
- German month mapping for Omron date parsing ("13 März 2026")
- Upsert logic preserves manual entries (source != 'manual')
- Import result feedback (inserted/updated/skipped/errors)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
682 lines
25 KiB
Python
682 lines
25 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
|
|
- POST /api/vitals/import/omron Import Omron CSV
|
|
- POST /api/vitals/import/apple-health Import Apple Health CSV
|
|
"""
|
|
from fastapi import APIRouter, HTTPException, Depends, Header, UploadFile, File
|
|
from pydantic import BaseModel
|
|
from typing import Optional
|
|
from datetime import datetime, timedelta
|
|
import logging
|
|
import csv
|
|
import io
|
|
from dateutil import parser as date_parser
|
|
|
|
from db import get_db, get_cursor, r2d
|
|
from auth import require_auth
|
|
|
|
router = APIRouter(prefix="/api/vitals", tags=["vitals"])
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# German month mapping for Omron dates
|
|
GERMAN_MONTHS = {
|
|
'Januar': '01', 'Jan.': '01',
|
|
'Februar': '02', 'Feb.': '02',
|
|
'März': '03',
|
|
'April': '04', 'Apr.': '04',
|
|
'Mai': '05',
|
|
'Juni': '06',
|
|
'Juli': '07',
|
|
'August': '08', 'Aug.': '08',
|
|
'September': '09', 'Sep.': '09',
|
|
'Oktober': '10', 'Okt.': '10',
|
|
'November': '11', 'Nov.': '11',
|
|
'Dezember': '12', 'Dez.': '12'
|
|
}
|
|
|
|
|
|
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,
|
|
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '7 days' THEN blood_pressure_systolic END) as avg_bp_sys_7d,
|
|
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '30 days' THEN blood_pressure_systolic END) as avg_bp_sys_30d,
|
|
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '7 days' THEN blood_pressure_diastolic END) as avg_bp_dia_7d,
|
|
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '30 days' THEN blood_pressure_diastolic END) as avg_bp_dia_30d,
|
|
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '7 days' THEN spo2 END) as avg_spo2_7d,
|
|
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '30 days' THEN spo2 END) as avg_spo2_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 latest VO2 Max
|
|
cur.execute(
|
|
"""
|
|
SELECT vo2_max
|
|
FROM vitals_log
|
|
WHERE profile_id = %s AND vo2_max IS NOT NULL
|
|
ORDER BY date DESC
|
|
LIMIT 1
|
|
""",
|
|
(pid,)
|
|
)
|
|
vo2_row = cur.fetchone()
|
|
latest_vo2 = vo2_row['vo2_max'] if vo2_row else None
|
|
|
|
# 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,
|
|
"avg_bp_systolic_7d": round(stats_row['avg_bp_sys_7d'], 1) if stats_row['avg_bp_sys_7d'] else None,
|
|
"avg_bp_systolic_30d": round(stats_row['avg_bp_sys_30d'], 1) if stats_row['avg_bp_sys_30d'] else None,
|
|
"avg_bp_diastolic_7d": round(stats_row['avg_bp_dia_7d'], 1) if stats_row['avg_bp_dia_7d'] else None,
|
|
"avg_bp_diastolic_30d": round(stats_row['avg_bp_dia_30d'], 1) if stats_row['avg_bp_dia_30d'] else None,
|
|
"avg_spo2_7d": round(stats_row['avg_spo2_7d'], 1) if stats_row['avg_spo2_7d'] else None,
|
|
"avg_spo2_30d": round(stats_row['avg_spo2_30d'], 1) if stats_row['avg_spo2_30d'] else None,
|
|
"latest_vo2_max": float(latest_vo2) if latest_vo2 else None,
|
|
"total_entries": stats_row['total_entries'],
|
|
"trend_resting_hr": trend_hr,
|
|
"trend_hrv": trend_hrv,
|
|
"period_days": days
|
|
}
|
|
|
|
|
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
# Import Endpoints
|
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
|
|
def parse_omron_date(date_str: str) -> str:
|
|
"""
|
|
Parse Omron German date format to YYYY-MM-DD.
|
|
|
|
Examples:
|
|
- "13 März 2026" -> "2026-03-13"
|
|
- "28 Feb. 2026" -> "2026-02-28"
|
|
"""
|
|
parts = date_str.strip().split()
|
|
if len(parts) != 3:
|
|
raise ValueError(f"Invalid date format: {date_str}")
|
|
|
|
day = parts[0].zfill(2)
|
|
month_str = parts[1]
|
|
year = parts[2]
|
|
|
|
# Map German month to number
|
|
month = GERMAN_MONTHS.get(month_str)
|
|
if not month:
|
|
raise ValueError(f"Unknown month: {month_str}")
|
|
|
|
return f"{year}-{month}-{day}"
|
|
|
|
|
|
@router.post("/import/omron")
|
|
async def import_omron_csv(
|
|
file: UploadFile = File(...),
|
|
x_profile_id: Optional[str] = Header(default=None),
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""
|
|
Import Omron blood pressure CSV export.
|
|
|
|
Expected format:
|
|
Datum,Zeit,Systolisch (mmHg),Diastolisch (mmHg),Puls (bpm),...
|
|
"""
|
|
pid = get_pid(x_profile_id, session)
|
|
|
|
# Read file
|
|
content = await file.read()
|
|
content_str = content.decode('utf-8')
|
|
|
|
# Parse CSV
|
|
reader = csv.DictReader(io.StringIO(content_str))
|
|
|
|
inserted = 0
|
|
updated = 0
|
|
skipped = 0
|
|
errors = []
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
for row_num, row in enumerate(reader, start=2):
|
|
try:
|
|
# Parse date
|
|
date_str = parse_omron_date(row['Datum'])
|
|
|
|
# Parse values
|
|
systolic = int(row['Systolisch (mmHg)']) if row['Systolisch (mmHg)'] and row['Systolisch (mmHg)'] != '-' else None
|
|
diastolic = int(row['Diastolisch (mmHg)']) if row['Diastolisch (mmHg)'] and row['Diastolisch (mmHg)'] != '-' else None
|
|
pulse = int(row['Puls (bpm)']) if row['Puls (bpm)'] and row['Puls (bpm)'] != '-' else None
|
|
|
|
# Skip if no data
|
|
if not systolic and not diastolic and not pulse:
|
|
skipped += 1
|
|
continue
|
|
|
|
# Parse flags (optional columns)
|
|
irregular = row.get('Unregelmäßiger Herzschlag festgestellt', '').strip() not in ('', '-', ' ')
|
|
afib = row.get('Mögliches AFib', '').strip() not in ('', '-', ' ')
|
|
|
|
# Upsert
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO vitals_log (
|
|
profile_id, date, blood_pressure_systolic, blood_pressure_diastolic,
|
|
pulse, irregular_heartbeat, possible_afib, source
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, 'omron')
|
|
ON CONFLICT (profile_id, date)
|
|
DO UPDATE SET
|
|
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),
|
|
irregular_heartbeat = COALESCE(EXCLUDED.irregular_heartbeat, vitals_log.irregular_heartbeat),
|
|
possible_afib = COALESCE(EXCLUDED.possible_afib, vitals_log.possible_afib),
|
|
source = CASE WHEN vitals_log.source = 'manual' THEN vitals_log.source ELSE 'omron' END,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
RETURNING (xmax = 0) AS inserted
|
|
""",
|
|
(pid, date_str, systolic, diastolic, pulse, irregular, afib)
|
|
)
|
|
|
|
result = cur.fetchone()
|
|
if result['inserted']:
|
|
inserted += 1
|
|
else:
|
|
updated += 1
|
|
|
|
except Exception as e:
|
|
errors.append(f"Zeile {row_num}: {str(e)}")
|
|
logger.error(f"[OMRON-IMPORT] Error at row {row_num}: {e}")
|
|
continue
|
|
|
|
conn.commit()
|
|
|
|
logger.info(f"[OMRON-IMPORT] {pid}: {inserted} inserted, {updated} updated, {skipped} skipped, {len(errors)} errors")
|
|
|
|
return {
|
|
"message": "Omron CSV Import abgeschlossen",
|
|
"inserted": inserted,
|
|
"updated": updated,
|
|
"skipped": skipped,
|
|
"errors": errors[:10] # Limit to first 10 errors
|
|
}
|
|
|
|
|
|
@router.post("/import/apple-health")
|
|
async def import_apple_health_csv(
|
|
file: UploadFile = File(...),
|
|
x_profile_id: Optional[str] = Header(default=None),
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""
|
|
Import Apple Health vitals CSV export.
|
|
|
|
Expected columns:
|
|
- Datum/Uhrzeit
|
|
- Ruhepuls (count/min)
|
|
- Herzfrequenzvariabilität (ms)
|
|
- VO2 max (ml/(kg·min))
|
|
- Blutsauerstoffsättigung (%)
|
|
- Atemfrequenz (count/min)
|
|
"""
|
|
pid = get_pid(x_profile_id, session)
|
|
|
|
# Read file
|
|
content = await file.read()
|
|
content_str = content.decode('utf-8')
|
|
|
|
# Parse CSV
|
|
reader = csv.DictReader(io.StringIO(content_str))
|
|
|
|
inserted = 0
|
|
updated = 0
|
|
skipped = 0
|
|
errors = []
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
for row_num, row in enumerate(reader, start=2):
|
|
try:
|
|
# Parse date (format: "2026-02-21 00:00:00")
|
|
date_str = row.get('Datum/Uhrzeit', '').split()[0] # Extract date part
|
|
if not date_str:
|
|
skipped += 1
|
|
continue
|
|
|
|
# Parse values (columns might be empty)
|
|
resting_hr = None
|
|
hrv = None
|
|
vo2_max = None
|
|
spo2 = None
|
|
respiratory_rate = None
|
|
|
|
if 'Ruhepuls (count/min)' in row and row['Ruhepuls (count/min)']:
|
|
resting_hr = int(float(row['Ruhepuls (count/min)']))
|
|
|
|
if 'Herzfrequenzvariabilität (ms)' in row and row['Herzfrequenzvariabilität (ms)']:
|
|
hrv = int(float(row['Herzfrequenzvariabilität (ms)']))
|
|
|
|
if 'VO2 max (ml/(kg·min))' in row and row['VO2 max (ml/(kg·min))']:
|
|
vo2_max = float(row['VO2 max (ml/(kg·min))'])
|
|
|
|
if 'Blutsauerstoffsättigung (%)' in row and row['Blutsauerstoffsättigung (%)']:
|
|
spo2 = int(float(row['Blutsauerstoffsättigung (%)']))
|
|
|
|
if 'Atemfrequenz (count/min)' in row and row['Atemfrequenz (count/min)']:
|
|
respiratory_rate = float(row['Atemfrequenz (count/min)'])
|
|
|
|
# Skip if no vitals data
|
|
if not any([resting_hr, hrv, vo2_max, spo2, respiratory_rate]):
|
|
skipped += 1
|
|
continue
|
|
|
|
# Upsert
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO vitals_log (
|
|
profile_id, date, resting_hr, hrv, vo2_max, spo2,
|
|
respiratory_rate, source
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, 'apple_health')
|
|
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),
|
|
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),
|
|
source = CASE WHEN vitals_log.source = 'manual' THEN vitals_log.source ELSE 'apple_health' END,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
RETURNING (xmax = 0) AS inserted
|
|
""",
|
|
(pid, date_str, resting_hr, hrv, vo2_max, spo2, respiratory_rate)
|
|
)
|
|
|
|
result = cur.fetchone()
|
|
if result['inserted']:
|
|
inserted += 1
|
|
else:
|
|
updated += 1
|
|
|
|
except Exception as e:
|
|
errors.append(f"Zeile {row_num}: {str(e)}")
|
|
logger.error(f"[APPLE-HEALTH-IMPORT] Error at row {row_num}: {e}")
|
|
continue
|
|
|
|
conn.commit()
|
|
|
|
logger.info(f"[APPLE-HEALTH-IMPORT] {pid}: {inserted} inserted, {updated} updated, {skipped} skipped, {len(errors)} errors")
|
|
|
|
return {
|
|
"message": "Apple Health CSV Import abgeschlossen",
|
|
"inserted": inserted,
|
|
"updated": updated,
|
|
"skipped": skipped,
|
|
"errors": errors[:10] # Limit to first 10 errors
|
|
}
|