385 lines
15 KiB
Python
385 lines
15 KiB
Python
"""Health check and metric endpoints for API v1."""
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from fastapi.responses import FileResponse
|
|
import os
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import text
|
|
from typing import List, Optional
|
|
import datetime
|
|
|
|
from backend.database import get_db
|
|
from backend.models.daily_health_metrics import DailyHealthMetrics
|
|
from backend.models.user import User
|
|
from backend.models.workout_session import WorkoutSession
|
|
from backend.schemas.daily_health_metrics import DailyHealthMetricsResponse
|
|
from backend import auth
|
|
|
|
router = APIRouter(prefix="/api/health", tags=["health"])
|
|
|
|
|
|
@router.get("", status_code=status.HTTP_200_OK)
|
|
def health_check(db: Session = Depends(get_db)):
|
|
"""Check if the application and database are healthy."""
|
|
try:
|
|
# Check database connection
|
|
db.execute(text("SELECT 1"))
|
|
return {
|
|
"status": "healthy",
|
|
"database": "connected"
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=503, detail=f"Unhealthy: {str(e)}")
|
|
|
|
|
|
|
|
# Valid endpoints start here
|
|
|
|
|
|
|
|
@router.get("/stats", response_model=List[DailyHealthMetricsResponse])
|
|
def get_health_stats(
|
|
period: str = "week",
|
|
ref_date: Optional[datetime.date] = None,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(auth.get_current_user)
|
|
):
|
|
"""Retrieve health statistics for various periods."""
|
|
|
|
# Imports for Raw Data
|
|
from backend.models.health_connect_raw import HCRawWeight
|
|
from sqlalchemy import func
|
|
|
|
# Helper to fetch raw weights
|
|
def get_raw_weights(start, end):
|
|
return db.query(HCRawWeight).filter(
|
|
HCRawWeight.user_id == current_user.id,
|
|
HCRawWeight.time >= start,
|
|
HCRawWeight.time <= end
|
|
).order_by(HCRawWeight.time.asc()).all()
|
|
|
|
if ref_date is None:
|
|
ref_date = datetime.date.today()
|
|
|
|
start_date = None
|
|
end_date = datetime.datetime.combine(ref_date, datetime.time.max) # End of ref day
|
|
|
|
if period == "day":
|
|
start_date = datetime.datetime.combine(ref_date, datetime.time.min)
|
|
elif period == "week":
|
|
start_date = datetime.datetime.combine(ref_date - datetime.timedelta(days=6), datetime.time.min)
|
|
elif period == "month":
|
|
start_date = datetime.datetime.combine(ref_date - datetime.timedelta(days=29), datetime.time.min)
|
|
elif period == "year":
|
|
start_date = datetime.datetime.combine(ref_date - datetime.timedelta(days=365), datetime.time.min)
|
|
|
|
# Base Metrics Query
|
|
metrics_query = db.query(DailyHealthMetrics).filter(
|
|
DailyHealthMetrics.user_id == current_user.id
|
|
)
|
|
|
|
if period == "day":
|
|
metrics_query = metrics_query.filter(DailyHealthMetrics.date == ref_date)
|
|
else:
|
|
metrics_query = metrics_query.filter(DailyHealthMetrics.date.between(start_date.date(), ref_date))
|
|
|
|
metrics_data = metrics_query.order_by(DailyHealthMetrics.date.asc()).all()
|
|
|
|
# If Year, handle aggregation specially
|
|
if period == "year":
|
|
# Raw weights for the year
|
|
raw_weights = get_raw_weights(start_date, end_date)
|
|
|
|
# Month-based aggregation for metrics
|
|
aggregated = []
|
|
import itertools
|
|
from statistics import mean
|
|
|
|
# Merge raw weights into metrics_data conceptually?
|
|
# Actually easier to aggregate metrics_data first, then overlay weight.
|
|
|
|
metrics_by_month = {}
|
|
for m in metrics_data:
|
|
key = (m.date.year, m.date.month)
|
|
if key not in metrics_by_month: metrics_by_month[key] = []
|
|
metrics_by_month[key].append(m)
|
|
|
|
weights_by_month = {}
|
|
for w in raw_weights:
|
|
key = (w.time.year, w.time.month)
|
|
if key not in weights_by_month: weights_by_month[key] = []
|
|
weights_by_month[key].append(w.weight_kg)
|
|
|
|
# distinct months from both sources
|
|
all_months = set(metrics_by_month.keys()) | set(weights_by_month.keys())
|
|
|
|
for key in sorted(all_months):
|
|
year, month = key
|
|
g_metrics = metrics_by_month.get(key, [])
|
|
g_weights = weights_by_month.get(key, [])
|
|
|
|
# Use first metric date or construct one
|
|
if g_metrics:
|
|
base_date = g_metrics[0].date.replace(day=1)
|
|
else:
|
|
base_date = datetime.date(year, month, 1)
|
|
|
|
agg = {
|
|
"id": int(f"{year}{month:02d}"), # Fake ID
|
|
"user_id": current_user.id,
|
|
"date": base_date,
|
|
# Defaults if no metrics
|
|
"step_count": (sum(x.step_count for x in g_metrics) // len(g_metrics)) if g_metrics else 0,
|
|
"step_count_total": sum(x.step_count for x in g_metrics) if g_metrics else 0,
|
|
"calories_burned": (sum(x.calories_burned for x in g_metrics) / len(g_metrics)) if g_metrics else 0,
|
|
"distance_meters": (sum(x.distance_meters for x in g_metrics) / len(g_metrics)) if g_metrics else 0,
|
|
"sleep_duration_minutes": (sum(x.sleep_duration_minutes for x in g_metrics) // len(g_metrics)) if g_metrics else 0,
|
|
"avg_heart_rate": (sum(x.avg_heart_rate or 0 for x in g_metrics) // len(g_metrics)) if g_metrics else 0,
|
|
# ... skip other fields for brevity unless critical ...
|
|
"min_heart_rate": min((x.min_heart_rate for x in g_metrics if x.min_heart_rate), default=0) if g_metrics else 0,
|
|
"max_heart_rate": max((x.max_heart_rate for x in g_metrics if x.max_heart_rate), default=0) if g_metrics else 0,
|
|
|
|
# WEIGHT from RAW (schema uses 'weight')
|
|
"weight": mean(g_weights) if g_weights else 0
|
|
}
|
|
# If weight missing in raw but present in aggregated (unlikely but possible)
|
|
if agg["weight"] == 0 and g_metrics:
|
|
# Fallback to avg of metrics weight if available
|
|
w_vals = [x.weight for x in g_metrics if x.weight]
|
|
if w_vals: agg["weight"] = mean(w_vals)
|
|
|
|
aggregated.append(agg)
|
|
|
|
return aggregated
|
|
|
|
# For Day/Week/Month
|
|
# Fetch Raw Weights
|
|
raw_weights = get_raw_weights(start_date, end_date)
|
|
|
|
# Map raw weights to date (take latest per date)
|
|
weights_by_date = {}
|
|
for w in raw_weights:
|
|
d = w.time.date()
|
|
weights_by_date[d] = w.weight_kg # overwrites with latest
|
|
|
|
# Merge into metrics_data
|
|
# 1. Update existing
|
|
processed_dates = set()
|
|
final_list = []
|
|
|
|
for m in metrics_data:
|
|
processed_dates.add(m.date)
|
|
# Overlay raw weight if available
|
|
if m.date in weights_by_date:
|
|
m.weight = weights_by_date[m.date] # set attribute dynamically
|
|
final_list.append(m)
|
|
|
|
# 2. Add missing dates (if we have weight but no daily metric)
|
|
for d, w in weights_by_date.items():
|
|
if d not in processed_dates:
|
|
# Create dummy object
|
|
dummy = DailyHealthMetricsResponse(
|
|
id=0,
|
|
user_id=current_user.id,
|
|
date=d,
|
|
step_count=0,
|
|
calories_burned=0,
|
|
distance_meters=0,
|
|
sleep_duration_minutes=0,
|
|
weight=w, # Our raw weight
|
|
# defaults
|
|
deep_sleep_minutes=0, light_sleep_minutes=0, rem_sleep_minutes=0, awake_duration_minutes=0,
|
|
pai_score=0, avg_heart_rate=0, min_heart_rate=0, max_heart_rate=0, avg_spo2=0, resting_heart_rate=0, hrv=0
|
|
)
|
|
final_list.append(dummy)
|
|
|
|
# Sort
|
|
final_list.sort(key=lambda x: x.date)
|
|
|
|
return final_list
|
|
|
|
|
|
@router.get("/workouts")
|
|
def get_workouts(
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
activity_type: Optional[str] = None,
|
|
start_date: Optional[datetime.date] = None,
|
|
end_date: Optional[datetime.date] = None,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(auth.get_current_user)
|
|
):
|
|
"""Retrieve workout sessions for the current user (merges manual and raw data)."""
|
|
from backend.models.workout_session import WorkoutSession
|
|
from backend.models.health_connect_raw import HCRawExerciseSession
|
|
|
|
start_dt = datetime.datetime.combine(start_date, datetime.time.min) if start_date else None
|
|
end_dt = datetime.datetime.combine(end_date, datetime.time.max) if end_date else None
|
|
|
|
# 1. Fetch from WorkoutSession
|
|
query_ws = db.query(WorkoutSession).filter(WorkoutSession.user_id == current_user.id)
|
|
if activity_type:
|
|
query_ws = query_ws.filter(WorkoutSession.activity_type == activity_type)
|
|
if start_dt:
|
|
query_ws = query_ws.filter(WorkoutSession.start_time >= start_dt)
|
|
if end_dt:
|
|
query_ws = query_ws.filter(WorkoutSession.start_time <= end_dt)
|
|
|
|
workouts_ws = query_ws.order_by(WorkoutSession.start_time.desc()).offset(offset).limit(limit).all()
|
|
|
|
# 2. Fetch from HCRawExerciseSession
|
|
query_raw = db.query(HCRawExerciseSession).filter(HCRawExerciseSession.user_id == current_user.id)
|
|
if activity_type:
|
|
query_raw = query_raw.filter(HCRawExerciseSession.exercise_type_name == activity_type)
|
|
if start_dt:
|
|
query_raw = query_raw.filter(HCRawExerciseSession.start_time >= start_dt)
|
|
if end_dt:
|
|
query_raw = query_raw.filter(HCRawExerciseSession.start_time <= end_dt)
|
|
|
|
workouts_raw = query_raw.order_by(HCRawExerciseSession.start_time.desc()).offset(offset).limit(limit).all()
|
|
|
|
# Combine and Map
|
|
combined = []
|
|
|
|
for w in workouts_ws:
|
|
combined.append({
|
|
"id": f"ws_{w.id}",
|
|
"user_id": w.user_id,
|
|
"start_time": w.start_time.isoformat() if w.start_time else None,
|
|
"end_time": w.end_time.isoformat() if w.end_time else None,
|
|
"activity_type": w.activity_type,
|
|
"activity_type_id": w.activity_type_id,
|
|
"duration_seconds": w.duration_seconds or 0,
|
|
"distance_meters": w.distance_meters or 0.0,
|
|
"calories": w.calories or 0.0,
|
|
"avg_pace": w.avg_pace,
|
|
"max_pace": w.max_pace,
|
|
"min_pace": w.min_pace,
|
|
"avg_hr": w.avg_hr,
|
|
"max_hr": w.max_hr,
|
|
"min_hr": w.min_hr,
|
|
"notes": w.notes,
|
|
"source": w.data_source
|
|
})
|
|
|
|
for w in workouts_raw:
|
|
duration = 0
|
|
if w.start_time and w.end_time:
|
|
duration = int((w.end_time - w.start_time).total_seconds())
|
|
|
|
combined.append({
|
|
"id": f"raw_{w.id}",
|
|
"user_id": w.user_id,
|
|
"start_time": w.start_time.isoformat() if w.start_time else None,
|
|
"end_time": w.end_time.isoformat() if w.end_time else None,
|
|
"activity_type": w.exercise_type_name or f"Activity {w.exercise_type}",
|
|
"activity_type_id": w.exercise_type,
|
|
"duration_seconds": duration,
|
|
"distance_meters": w.distance_meters or 0.0,
|
|
"calories": w.calories or 0.0,
|
|
"avg_pace": None,
|
|
"max_pace": None,
|
|
"min_pace": None,
|
|
"avg_hr": w.avg_heart_rate,
|
|
"max_hr": w.max_heart_rate,
|
|
"min_hr": w.min_heart_rate,
|
|
"notes": w.notes,
|
|
"source": "health_connect"
|
|
})
|
|
|
|
# Sort combined list by start_time DESC
|
|
combined.sort(key=lambda x: x["start_time"] if x["start_time"] else "", reverse=True)
|
|
|
|
return combined[:limit]
|
|
|
|
|
|
@router.get("/workouts/summary")
|
|
def get_workout_summary(
|
|
start_date: Optional[datetime.date] = None,
|
|
end_date: Optional[datetime.date] = None,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(auth.get_current_user)
|
|
):
|
|
"""Get summary statistics for workouts (combines manual and raw data)."""
|
|
from backend.models.workout_session import WorkoutSession
|
|
from backend.models.health_connect_raw import HCRawExerciseSession
|
|
from sqlalchemy import func
|
|
|
|
start_dt = datetime.datetime.combine(start_date, datetime.time.min) if start_date else None
|
|
end_dt = datetime.datetime.combine(end_date, datetime.time.max) if end_date else None
|
|
|
|
# 1. Stats from WorkoutSession
|
|
query_ws = db.query(
|
|
WorkoutSession.activity_type,
|
|
func.count(WorkoutSession.id).label('count'),
|
|
func.sum(WorkoutSession.duration_seconds).label('total_duration'),
|
|
func.sum(WorkoutSession.calories).label('total_calories'),
|
|
func.sum(WorkoutSession.distance_meters).label('total_distance')
|
|
).filter(WorkoutSession.user_id == current_user.id)
|
|
|
|
if start_dt:
|
|
query_ws = query_ws.filter(WorkoutSession.start_time >= start_dt)
|
|
if end_dt:
|
|
query_ws = query_ws.filter(WorkoutSession.start_time <= end_dt)
|
|
|
|
by_type_ws = query_ws.group_by(WorkoutSession.activity_type).all()
|
|
|
|
# 2. Stats from HCRawExerciseSession
|
|
# We fetch the sessions and aggregate in Python to avoid SQLite date extraction issues
|
|
query_raw = db.query(HCRawExerciseSession).filter(HCRawExerciseSession.user_id == current_user.id)
|
|
|
|
if start_dt:
|
|
query_raw = query_raw.filter(HCRawExerciseSession.start_time >= start_dt)
|
|
if end_dt:
|
|
query_raw = query_raw.filter(HCRawExerciseSession.start_time <= end_dt)
|
|
|
|
sessions_raw = query_raw.all()
|
|
|
|
# Merge results
|
|
summary_map = {}
|
|
|
|
# Process manual sessions
|
|
for row in by_type_ws:
|
|
name = row[0] or "Unknown"
|
|
summary_map[name] = {
|
|
"count": row[1],
|
|
"duration": row[2] or 0,
|
|
"calories": row[3] or 0,
|
|
"distance": row[4] or 0
|
|
}
|
|
|
|
# Process raw sessions
|
|
for w in sessions_raw:
|
|
name = w.exercise_type_name or f"Activity {w.exercise_type}"
|
|
duration = 0
|
|
if w.start_time and w.end_time:
|
|
duration = (w.end_time - w.start_time).total_seconds()
|
|
|
|
if name in summary_map:
|
|
summary_map[name]["count"] += 1
|
|
summary_map[name]["duration"] += duration
|
|
summary_map[name]["calories"] += (w.calories or 0)
|
|
summary_map[name]["distance"] += (w.distance_meters or 0)
|
|
else:
|
|
summary_map[name] = {
|
|
"count": 1,
|
|
"duration": duration,
|
|
"calories": w.calories or 0,
|
|
"distance": w.distance_meters or 0
|
|
}
|
|
|
|
total_workouts = sum(s["count"] for s in summary_map.values())
|
|
|
|
return {
|
|
"total_workouts": total_workouts,
|
|
"by_activity_type": [
|
|
{
|
|
"activity_type": name,
|
|
"count": stats["count"],
|
|
"total_duration_seconds": int(stats["duration"]),
|
|
"total_calories": round(stats["calories"], 1),
|
|
"total_distance_meters": round(stats["distance"], 1)
|
|
}
|
|
for name, stats in summary_map.items()
|
|
]
|
|
}
|