Add initial project

This commit is contained in:
2026-07-30 23:25:20 -04:00
commit cff6aa8289
164 changed files with 26672 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""API module for HabitForge."""
+1
View File
@@ -0,0 +1 @@
"""API v1 module for HabitForge."""
+107
View File
@@ -0,0 +1,107 @@
"""Authentication endpoints for API v1."""
from fastapi import APIRouter, Depends, HTTPException, status, Request, Body
from fastapi.security import OAuth2PasswordRequestForm
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from typing import List
from backend.models.user import User
from backend import auth
from backend.schemas.user import UserCreate, UserResponse
from backend.schemas.token import Token
from backend.database import get_db
from backend.core.config import settings
from slowapi import Limiter
from slowapi.util import get_remote_address
router = APIRouter(prefix="/api/auth", tags=["authentication"])
limiter = Limiter(key_func=get_remote_address)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit(f"{settings.RATE_LIMIT_TIMES}/{settings.RATE_LIMIT_SECONDS}s")
def register(request: Request, user: UserCreate, db: Session = Depends(get_db)):
"""Register a new user."""
db_user = db.query(User).filter(User.username == user.username).first()
if db_user:
raise HTTPException(status_code=400, detail="Username already registered")
hashed_password = auth.get_password_hash(user.password)
new_user = User(username=user.username, hashed_password=hashed_password)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
@router.post("/token", response_model=Token)
@limiter.limit(f"{settings.RATE_LIMIT_TIMES}/{settings.RATE_LIMIT_SECONDS}s")
def login_for_access_token(
request: Request,
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
"""Login and get access token."""
user = db.query(User).filter(User.username == form_data.username).first()
if not user or not auth.verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
from datetime import timedelta
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = auth.create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
refresh_token_expires = timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
refresh_token = auth.create_refresh_token(
data={"sub": user.username}, expires_delta=refresh_token_expires
)
return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer"}
@router.post("/refresh", response_model=Token)
@limiter.limit(f"{settings.RATE_LIMIT_TIMES}/{settings.RATE_LIMIT_SECONDS}s")
def refresh_token(
request: Request,
refresh_token: str = Body(..., embed=True),
db: Session = Depends(get_db)
):
"""Refresh access token using refresh token."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(refresh_token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.username == username).first()
if user is None:
raise credentials_exception
from datetime import timedelta
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = auth.create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
refresh_token_expires = timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
new_refresh_token = auth.create_refresh_token(
data={"sub": user.username}, expires_delta=refresh_token_expires
)
return {"access_token": access_token, "refresh_token": new_refresh_token, "token_type": "bearer"}
@router.get("/me", response_model=UserResponse)
def read_users_me(current_user: User = Depends(auth.get_current_user)):
"""Get current user information."""
return current_user
+118
View File
@@ -0,0 +1,118 @@
"""Challenge endpoints for API v1."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List, Optional
import datetime
from backend.models.challenge import Challenge
from backend.models.tracking import Tracking
from backend.models.user import User
from backend import auth
from backend.schemas.challenge import ChallengeCreate, ChallengeResponse
from backend.schemas.tracking import TrackingCreate, TrackingResponse
from backend.database import get_db
router = APIRouter(prefix="/api/challenges", tags=["challenges"])
@router.post("", response_model=ChallengeResponse, status_code=status.HTTP_201_CREATED)
def create_challenge(
challenge: ChallengeCreate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Create a new challenge."""
# Calculate next display_order for THIS user
max_order = db.query(func.max(Challenge.display_order)).filter(
Challenge.user_id == current_user.id
).scalar()
new_order = (max_order if max_order is not None else -1) + 1
db_challenge = Challenge(**challenge.dict(), display_order=new_order, user_id=current_user.id)
db.add(db_challenge)
db.commit()
db.refresh(db_challenge)
return db_challenge
@router.get("", response_model=List[ChallengeResponse])
def read_challenges(
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get all challenges for the current user."""
challenges = db.query(Challenge).filter(
Challenge.user_id == current_user.id
).order_by(Challenge.display_order.asc()).all()
return challenges
@router.put("/reorder", status_code=status.HTTP_204_NO_CONTENT)
def reorder_challenges(
ordered_ids: List[int],
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Reorder challenges."""
# Sanity check: ensure these challenges belong to user
for index, challenge_id in enumerate(ordered_ids):
db.query(Challenge).filter(
Challenge.id == challenge_id,
Challenge.user_id == current_user.id
).update({"display_order": index})
db.commit()
return None
@router.delete("/{challenge_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_challenge(
challenge_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Delete a challenge."""
db_challenge = db.query(Challenge).filter(
Challenge.id == challenge_id,
Challenge.user_id == current_user.id
).first()
if not db_challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
db.delete(db_challenge)
db.commit()
return None
@router.post("/{challenge_id}/tracking", response_model=TrackingResponse)
def log_tracking(
challenge_id: int,
tracking: TrackingCreate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Log tracking for a challenge."""
# Verify challenge belongs to user
challenge = db.query(Challenge).filter(
Challenge.id == challenge_id,
Challenge.user_id == current_user.id
).first()
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
# Check if tracking exists for this date and challenge
db_tracking = db.query(Tracking).filter(
Tracking.challenge_id == challenge_id,
Tracking.date == tracking.date
).first()
if db_tracking:
db_tracking.reps = tracking.reps
db_tracking.completed = tracking.completed
db_tracking.notes = tracking.notes
else:
db_tracking = Tracking(**tracking.dict())
db.add(db_tracking)
db.commit()
db.refresh(db_tracking)
return db_tracking
+384
View File
@@ -0,0 +1,384 @@
"""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()
]
}
+598
View File
@@ -0,0 +1,598 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import func, desc, distinct
from typing import List, Optional, Dict, Any
from datetime import datetime, date, timedelta
import logging
from backend.database import get_db
from backend.models.user import User
from backend.models.health_connect_raw import (
HCRawSteps,
HCRawHeartRate,
HCRawHeartRateSample,
HCRawRestingHeartRate,
HCRawSleepSession,
HCRawSleepStage,
HCRawDistance,
HCRawCalories,
HCRawOxygenSaturation,
HCRawWeight,
HCRawExerciseSession,
HCRawHeartRateVariabilityRmssd,
SLEEP_STAGE_TYPES
)
from backend.models.daily_context import DailyContext
from pydantic import BaseModel
from backend import auth
router = APIRouter(prefix="/api/analytics", tags=["health-analytics"])
logger = logging.getLogger(__name__)
@router.get("/sources", response_model=List[Dict[str, str]])
async def get_available_sources(
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Get unique data sources available in the raw data.
Checks Steps and Sleep as primary indicators.
"""
# Query distinct sources from Steps
step_sources = db.query(
HCRawSteps.data_source,
HCRawSteps.data_source_name
).filter(
HCRawSteps.user_id == current_user.id
).distinct().all()
# Query distinct sources from Sleep
sleep_sources = db.query(
HCRawSleepSession.data_source,
HCRawSleepSession.data_source_name
).filter(
HCRawSleepSession.user_id == current_user.id
).distinct().all()
# Combine and Dedupe
sources_map = {}
for src, name in step_sources:
if src not in sources_map:
sources_map[src] = name or src
for src, name in sleep_sources:
if src not in sources_map:
sources_map[src] = name or src
return [
{"package": k, "name": v}
for k, v in sources_map.items()
]
@router.get("/daily", response_model=Dict[str, Any])
async def get_daily_analytics(
date_str: str, # YYYY-MM-DD
source: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Get aggregated daily heath metrics from raw data for a specific date.
"""
try:
target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
start_dt = datetime.combine(target_date, datetime.min.time())
end_dt = datetime.combine(target_date, datetime.max.time())
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
stats = {}
# --- STEPS ---
steps_query = db.query(func.sum(HCRawSteps.count)).filter(
HCRawSteps.user_id == current_user.id,
HCRawSteps.start_time >= start_dt,
HCRawSteps.start_time <= end_dt
)
if source:
steps_query = steps_query.filter(HCRawSteps.data_source == source)
stats["steps"] = steps_query.scalar() or 0
# --- CALORIES ---
# Sum total calories
cals_query = db.query(func.sum(HCRawCalories.calories)).filter(
HCRawCalories.user_id == current_user.id,
HCRawCalories.start_time >= start_dt,
HCRawCalories.start_time <= end_dt
)
if source:
cals_query = cals_query.filter(HCRawCalories.data_source == source)
stats["calories"] = round(cals_query.scalar() or 0, 1)
# --- DISTANCE ---
dist_query = db.query(func.sum(HCRawDistance.distance_meters)).filter(
HCRawDistance.user_id == current_user.id,
HCRawDistance.start_time >= start_dt,
HCRawDistance.start_time <= end_dt
)
if source:
dist_query = dist_query.filter(HCRawDistance.data_source == source)
stats["distance_meters"] = round(dist_query.scalar() or 0, 1)
# --- SLEEP (Previous Night) ---
# Usually sleep for "today" means sleep ending today (morning).
# Logic: Sleep sessions ending between today 00:00 and today 23:59 (or noon to noon?)
# Health Connect usually assigns sleep to the day it ends.
# We sum duration of sessions overlap. Simpler: Sum duration of sessions ending on this date.
sleep_query = db.query(HCRawSleepSession).filter(
HCRawSleepSession.user_id == current_user.id,
HCRawSleepSession.end_time >= start_dt,
HCRawSleepSession.end_time <= end_dt
)
if source:
sleep_query = sleep_query.filter(HCRawSleepSession.data_source == source)
sessions = sleep_query.all()
total_sleep_seconds = 0
for s in sessions:
duration = (s.end_time - s.start_time).total_seconds()
total_sleep_seconds += duration
session_total_minutes = int(total_sleep_seconds / 60)
# Track bedtime/wake (earliest start, latest end)
bedtime = min([s.start_time for s in sessions]) if sessions else None
wake_time = max([s.end_time for s in sessions]) if sessions else None
stats["bedtime"] = bedtime.isoformat() if bedtime else None
stats["wake_time"] = wake_time.isoformat() if wake_time else None
# --- HEART RATE (Resting) ---
rhr_query = db.query(func.avg(HCRawRestingHeartRate.bpm)).filter(
HCRawRestingHeartRate.user_id == current_user.id,
HCRawRestingHeartRate.time >= start_dt,
HCRawRestingHeartRate.time <= end_dt
)
if source:
rhr_query = rhr_query.filter(HCRawRestingHeartRate.data_source == source)
stats["resting_hr"] = int(rhr_query.scalar() or 0)
# --- WEIGHT (Latest) ---
weight_query = db.query(HCRawWeight.weight_kg).filter(
HCRawWeight.user_id == current_user.id,
HCRawWeight.time <= end_dt
).order_by(HCRawWeight.time.desc())
if source:
weight_query = weight_query.filter(HCRawWeight.data_source == source)
latest_weight = weight_query.first()
stats["weight_kg"] = round(latest_weight[0], 1) if latest_weight else None
# --- SLEEP STAGES ---
# Sum duration per stage for sessions ending today
stages_query = db.query(
HCRawSleepStage.stage_name,
func.sum(func.extract('epoch', HCRawSleepStage.end_time) - func.extract('epoch', HCRawSleepStage.start_time))
).join(HCRawSleepSession).filter(
HCRawSleepSession.user_id == current_user.id,
HCRawSleepSession.end_time >= start_dt,
HCRawSleepSession.end_time <= end_dt
)
if source:
stages_query = stages_query.filter(HCRawSleepSession.data_source == source)
stages_result = stages_query.group_by(HCRawSleepStage.stage_name).all()
stats["deep_sleep_minutes"] = 0
stats["light_sleep_minutes"] = 0
stats["rem_sleep_minutes"] = 0
stats["awake_duration_minutes"] = 0
for stage_name, duration_seconds in stages_result:
if not duration_seconds:
continue
minutes = int(duration_seconds / 60)
# Normalize stage names
name = stage_name.lower() if stage_name else "unknown"
if "deep" in name:
stats["deep_sleep_minutes"] += minutes
elif "light" in name:
stats["light_sleep_minutes"] += minutes
elif "rem" in name:
stats["rem_sleep_minutes"] += minutes
elif "awake" in name:
stats["awake_duration_minutes"] += minutes
# --- SLEEP HEART RATE ---
if bedtime and wake_time:
sleep_hr_query = db.query(func.avg(HCRawHeartRateSample.bpm)).join(HCRawHeartRate).filter(
HCRawHeartRate.user_id == current_user.id,
HCRawHeartRateSample.time >= bedtime,
HCRawHeartRateSample.time <= wake_time
)
if source:
sleep_hr_query = sleep_hr_query.filter(HCRawHeartRate.data_source == source)
stats["sleep_avg_hr"] = int(sleep_hr_query.scalar() or 0)
else:
stats["sleep_avg_hr"] = None
# Final Sleep Calculation: Total Sleep = Deep + Light + REM
# This excludes Awake time which is often included in session duration
actual_sleep = stats["deep_sleep_minutes"] + stats["light_sleep_minutes"] + stats["rem_sleep_minutes"]
if actual_sleep > 0:
stats["sleep_minutes"] = actual_sleep
else:
# Fallback to session duration (minus awake if reported as a stage)
stats["sleep_minutes"] = max(0, session_total_minutes - stats["awake_duration_minutes"])
# --- HEART RATE DETAILED (Min/Max/Avg) ---
# Query samples directly for accuracy
hr_stats_query = db.query(
func.min(HCRawHeartRateSample.bpm),
func.max(HCRawHeartRateSample.bpm),
func.avg(HCRawHeartRateSample.bpm)
).join(HCRawHeartRate).filter(
HCRawHeartRate.user_id == current_user.id,
HCRawHeartRateSample.time >= start_dt,
HCRawHeartRateSample.time <= end_dt
)
if source:
hr_stats_query = hr_stats_query.filter(HCRawHeartRate.data_source == source)
min_hr, max_hr, avg_hr = hr_stats_query.first()
stats["min_heart_rate"] = min_hr
stats["max_heart_rate"] = max_hr
stats["avg_heart_rate"] = int(avg_hr) if avg_hr else None
# --- HRV ---
hrv_query = db.query(func.avg(HCRawHeartRateVariabilityRmssd.rmssd)).filter(
HCRawHeartRateVariabilityRmssd.user_id == current_user.id,
HCRawHeartRateVariabilityRmssd.time >= start_dt,
HCRawHeartRateVariabilityRmssd.time <= end_dt
)
if source:
hrv_query = hrv_query.filter(HCRawHeartRateVariabilityRmssd.data_source == source)
avg_hrv = hrv_query.scalar()
stats["hrv"] = int(avg_hrv) if avg_hrv else None
# --- USER CONTEXT ---
context = db.query(DailyContext).filter(
DailyContext.user_id == current_user.id,
DailyContext.date == target_date
).first()
stats["user_context"] = {
"mood_score": context.mood_score if context else None,
"tags": context.tags.split(",") if context and context.tags else [],
"notes": context.notes if context else None
}
# --- READINESS CALCULATION ---
# 1. Fetch 30-day baselines for HRV and RHR
baseline_start = start_dt - timedelta(days=30)
baseline_hrv = float(db.query(func.avg(HCRawHeartRateVariabilityRmssd.rmssd)).filter(
HCRawHeartRateVariabilityRmssd.user_id == current_user.id,
HCRawHeartRateVariabilityRmssd.time >= baseline_start,
HCRawHeartRateVariabilityRmssd.time < start_dt
).scalar() or 40.0) # Default fallback
baseline_rhr = float(db.query(func.avg(HCRawRestingHeartRate.bpm)).filter(
HCRawRestingHeartRate.user_id == current_user.id,
HCRawRestingHeartRate.time >= baseline_start,
HCRawRestingHeartRate.time < start_dt
).scalar() or 60.0) # Default fallback
# 2. Mental Score (Nervous System)
# HRV: Higher is better. RHR: Lower is better.
current_hrv = float(stats["hrv"] or baseline_hrv)
current_rhr = float(stats["resting_hr"] or baseline_rhr)
hrv_component = (current_hrv / baseline_hrv) * 100
rhr_component = (baseline_rhr / current_rhr) * 100
mental_score = min(100, max(0, (hrv_component + rhr_component) / 2))
# 3. Physical Score (Sleep + Activity)
# Sleep goal: 8h (480 min).
sleep_score = min(100, (stats["sleep_minutes"] / 480) * 100)
# Simple physical placeholder: just sleep for now as we don't have "yesterday's strain" handy easily without another query
# TODO: Add previous day strain integration
physical_score = sleep_score
overall_score = int((mental_score * 0.5) + (physical_score * 0.5))
# Recommendation Logic
recommendation = ""
if overall_score >= 80:
recommendation = "All systems go! Your body and mind are primed for high intensity."
elif overall_score < 40:
recommendation = "Rest day advised. Focus on recovery and sleep."
else:
# Mixed signals
if mental_score < physical_score - 10:
recommendation = "Body is ready, but nervous system is tired. Avoid HIIT, try steady cardio or yoga."
elif physical_score < mental_score - 10:
recommendation = "Mind is fresh, but body needs rest. Light technical work or mobility."
else:
recommendation = "Moderate intensity recommended. Listen to your body."
stats["readiness"] = {
"score": overall_score,
"mental_score": int(mental_score),
"physical_score": int(physical_score),
"hrv_baseline": int(baseline_hrv),
"rhr_baseline": int(baseline_rhr),
"recommendation": recommendation
}
return stats
@router.get("/range", response_model=Dict[str, Any])
async def get_range_analytics(
start_date: str,
end_date: str,
source: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Get daily breakdown for a date range (e.g. for charts).
"""
try:
s_date = datetime.strptime(start_date, "%Y-%m-%d").date()
e_date = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format")
# Generate list of dates
delta = e_date - s_date
days = []
for i in range(delta.days + 1):
day = s_date + timedelta(days=i)
days.append(day)
results = []
# Optimized: We could do a big Group By query, but simplistic approach first loop is safer for logic correctness
# For performance on 30 days, we should use SQL Group By.
# STEPS Group By
start_dt = datetime.combine(s_date, datetime.min.time())
end_dt = datetime.combine(e_date, datetime.max.time())
# helper for range query
def get_daily_sum(model, value_col):
qs = db.query(
func.date(model.start_time).label("day"),
func.sum(value_col)
).filter(
model.user_id == current_user.id,
model.start_time >= start_dt,
model.start_time <= end_dt
)
if source:
qs = qs.filter(model.data_source == source)
return dict(qs.group_by(func.date(model.start_time)).all())
steps_data = get_daily_sum(HCRawSteps, HCRawSteps.count)
cals_data = get_daily_sum(HCRawCalories, HCRawCalories.calories)
dist_data = get_daily_sum(HCRawDistance, HCRawDistance.distance_meters)
# Resting HR (Average per day)
rhr_qs = db.query(
func.date(HCRawRestingHeartRate.time).label("day"),
func.avg(HCRawRestingHeartRate.bpm)
).filter(
HCRawRestingHeartRate.user_id == current_user.id,
HCRawRestingHeartRate.time >= start_dt,
HCRawRestingHeartRate.time <= end_dt
)
if source:
rhr_qs = rhr_qs.filter(HCRawRestingHeartRate.data_source == source)
rhr_data = dict(rhr_qs.group_by(func.date(HCRawRestingHeartRate.time)).all())
# HRV (Average per day)
hrv_qs = db.query(
func.date(HCRawHeartRateVariabilityRmssd.time).label("day"),
func.avg(HCRawHeartRateVariabilityRmssd.rmssd)
).filter(
HCRawHeartRateVariabilityRmssd.user_id == current_user.id,
HCRawHeartRateVariabilityRmssd.time >= start_dt,
HCRawHeartRateVariabilityRmssd.time <= end_dt
)
if source:
hrv_qs = hrv_qs.filter(HCRawHeartRateVariabilityRmssd.data_source == source)
hrv_data = dict(hrv_qs.group_by(func.date(HCRawHeartRateVariabilityRmssd.time)).all())
# Sleep is trickier. We want Deep + Light + REM, excluding Awake.
# 1. Query stages excluding awake
sleep_stages_qs = db.query(
func.date(HCRawSleepSession.end_time).label("day"),
func.sum(func.extract('epoch', HCRawSleepStage.end_time) - func.extract('epoch', HCRawSleepStage.start_time))
).join(HCRawSleepStage).filter(
HCRawSleepSession.user_id == current_user.id,
HCRawSleepSession.end_time >= start_dt,
HCRawSleepSession.end_time <= end_dt,
func.lower(HCRawSleepStage.stage_name).notlike('%awake%')
).group_by(func.date(HCRawSleepSession.end_time))
if source:
sleep_stages_qs = sleep_stages_qs.filter(HCRawSleepSession.data_source == source)
sleep_stages_map = {str(d): s/60 for d, s in sleep_stages_qs.all()}
# 2. Query session totals as fallback
sleep_sess_qs = db.query(
func.date(HCRawSleepSession.end_time).label("day"),
func.sum(func.extract('epoch', HCRawSleepSession.end_time) - func.extract('epoch', HCRawSleepSession.start_time))
).filter(
HCRawSleepSession.user_id == current_user.id,
HCRawSleepSession.end_time >= start_dt,
HCRawSleepSession.end_time <= end_dt
).group_by(func.date(HCRawSleepSession.end_time))
if source:
sleep_sess_qs = sleep_sess_qs.filter(HCRawSleepSession.data_source == source)
sleep_sess_map = {str(d): s/60 for d, s in sleep_sess_qs.all()}
# Merge: Prioritize stages
sleep_data = {}
for d_str in set(list(sleep_stages_map.keys()) + list(sleep_sess_map.keys())):
sleep_data[d_str] = sleep_stages_map.get(d_str) or sleep_sess_map.get(d_str) or 0
# 3. Query bedtime/wake per day
sleep_times_qs = db.query(
func.date(HCRawSleepSession.end_time).label("day"),
func.min(HCRawSleepSession.start_time),
func.max(HCRawSleepSession.end_time)
).filter(
HCRawSleepSession.user_id == current_user.id,
HCRawSleepSession.end_time >= start_dt,
HCRawSleepSession.end_time <= end_dt
).group_by(func.date(HCRawSleepSession.end_time))
if source:
sleep_times_qs = sleep_times_qs.filter(HCRawSleepSession.data_source == source)
sleep_times_map = {str(d): {"bedtime": b.isoformat(), "wake_time": w.isoformat()} for d, b, w in sleep_times_qs.all()}
# Construct response
daily_data = []
for day in days:
day_str = day.strftime("%Y-%m-%d")
# Helper to safely get value from dict with string or date keys
def get_val(data_dict):
val = data_dict.get(day_str, 0)
if not val and data_dict.get(day):
val = data_dict.get(day)
return val or 0
times = sleep_times_map.get(day_str, {})
daily_data.append({
"date": day_str,
"steps": int(get_val(steps_data)),
"calories": round(get_val(cals_data), 1),
"distance_meters": round(get_val(dist_data), 1),
"resting_hr": int(get_val(rhr_data)),
"hrv": int(get_val(hrv_data)),
"sleep_minutes": int(sleep_data.get(day_str, 0)),
"bedtime": times.get("bedtime"),
"wake_time": times.get("wake_time"),
"mood_score": 0, # Placeholder
"strain": int(get_val(cals_data) / 20), # Rough strain proxy (2000 cal = 100 strain)
"recovery": 0 # Placeholder
})
# Enrich with Context and simple readiness (optimized)
# Fetch all context for range
context_map = {}
contexts = db.query(DailyContext).filter(
DailyContext.user_id == current_user.id,
DailyContext.date >= s_date,
DailyContext.date <= e_date
).all()
for c in contexts:
context_map[str(c.date)] = {"mood": c.mood_score, "tags": c.tags}
# Calculate baselines once (using end_date for simplicity)
baseline_start = e_date - timedelta(days=30)
base_hrv = float(db.query(func.avg(HCRawHeartRateVariabilityRmssd.rmssd)).filter(
HCRawHeartRateVariabilityRmssd.user_id == current_user.id,
HCRawHeartRateVariabilityRmssd.time >= datetime.combine(baseline_start, datetime.min.time()),
HCRawHeartRateVariabilityRmssd.time <= datetime.combine(e_date, datetime.max.time())
).scalar() or 40.0)
base_rhr = float(db.query(func.avg(HCRawRestingHeartRate.bpm)).filter(
HCRawRestingHeartRate.user_id == current_user.id,
HCRawRestingHeartRate.time >= datetime.combine(baseline_start, datetime.min.time()),
HCRawRestingHeartRate.time <= datetime.combine(e_date, datetime.max.time())
).scalar() or 60.0)
for d in daily_data:
d_date = d["date"]
# Context
if d_date in context_map:
d["mood_score"] = context_map[d_date]["mood"]
d["tags"] = context_map[d_date]["tags"]
# Readiness Calculation
d_hrv = float(d["hrv"] or base_hrv)
d_rhr = float(d["resting_hr"] or base_rhr)
d_sleep = d["sleep_minutes"]
mental = ((d_hrv / base_hrv) * 0.5 + (base_rhr / d_rhr) * 0.5) * 100
physical = min(100, (d_sleep / 480) * 100)
d["readiness"] = int((mental + physical) / 2)
d["recovery_score"] = int(mental) # Use mental as "Recovery" for the chart green bar? Or overall readiness.
return {
"daily": daily_data,
"baselines": {
"hrv": int(base_hrv),
"rhr": int(base_rhr)
}
}
class ContextUpdate(BaseModel):
date: str
mood: int
tags: Optional[str] = None
notes: Optional[str] = None
@router.post("/context", status_code=status.HTTP_201_CREATED)
async def update_daily_context(
update: ContextUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Update subjective user context (mood, tags) for a specific date.
"""
try:
target_date = datetime.strptime(update.date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format")
context = db.query(DailyContext).filter(
DailyContext.user_id == current_user.id,
DailyContext.date == target_date
).first()
if not context:
context = DailyContext(
user_id=current_user.id,
date=target_date,
mood_score=update.mood,
tags=update.tags,
notes=update.notes
)
db.add(context)
else:
context.mood_score = update.mood
context.tags = update.tags
context.notes = update.notes
db.commit()
db.refresh(context)
return {"status": "success", "id": context.id}
+363
View File
@@ -0,0 +1,363 @@
"""
Health Connect API - Endpoints for receiving data from Android companion app.
These endpoints allow the Android companion app to push health data
from Health Connect to the HabitForge backend.
"""
from fastapi import APIRouter, Depends, HTTPException, status, Header
from sqlalchemy.orm import Session
from typing import List, Optional
from pydantic import BaseModel
from datetime import datetime, date
import hashlib
import hmac
import logging
logger = logging.getLogger(__name__)
from backend.database import get_db
from backend.models.user import User
from backend.models.health_connect import (
HealthConnectSyncLog,
HealthConnectDailyMetrics,
HealthConnectExerciseSession,
HEALTH_CONNECT_EXERCISE_TYPES,
)
from backend.models.daily_health_metrics import DailyHealthMetrics
from backend.models.workout_session import WorkoutSession
from backend import auth
from backend.core.config import settings
router = APIRouter(prefix="/api/health-connect", tags=["health-connect"])
# --- Request Models ---
class DailyMetricsPayload(BaseModel):
"""Payload for daily health metrics from Health Connect."""
date: date
step_count: int = 0
distance_meters: float = 0.0
calories_burned: float = 0.0
total_calories: float = 0.0
sleep_duration_minutes: int = 0
deep_sleep_minutes: int = 0
light_sleep_minutes: int = 0
rem_sleep_minutes: int = 0
awake_duration_minutes: int = 0
avg_heart_rate: Optional[int] = None
min_heart_rate: Optional[int] = None
max_heart_rate: Optional[int] = None
resting_heart_rate: Optional[int] = None
avg_spo2: Optional[float] = None
min_spo2: Optional[float] = None
max_spo2: Optional[float] = None
weight: Optional[float] = None
# Data source tracking
data_source: Optional[str] = None # Package name (e.g., "com.huami.watch.hmwatchmanager")
data_source_name: Optional[str] = None # Friendly name (e.g., "Zepp")
class ExerciseSessionPayload(BaseModel):
"""Payload for exercise session from Health Connect."""
start_time: datetime
end_time: datetime
exercise_type: int # Health Connect ExerciseType enum value
exercise_type_name: Optional[str] = None
duration_seconds: int = 0
distance_meters: Optional[float] = None
calories_burned: Optional[float] = None
avg_heart_rate: Optional[int] = None
max_heart_rate: Optional[int] = None
min_heart_rate: Optional[int] = None
steps: Optional[int] = None
elevation_gained: Optional[float] = None
notes: Optional[str] = None
class HealthConnectSyncRequest(BaseModel):
"""Full sync request from Android companion app."""
device_id: str
sync_timestamp: str = "" # Added for Android compatibility
daily_metrics: List[DailyMetricsPayload] = []
exercise_sessions: List[ExerciseSessionPayload] = []
class SyncResponse(BaseModel):
"""Response for sync operation."""
status: str
sync_id: int
metrics_synced: int # Renamed from metrics_processed to match Android
exercises_synced: int # Renamed from sessions_processed to match Android
message: str
# --- Endpoints ---
@router.post("/sync", response_model=SyncResponse, status_code=status.HTTP_200_OK)
async def receive_health_connect_data(
request: HealthConnectSyncRequest,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Receive health data pushed from Android companion app.
This endpoint accepts data from Health Connect and stores it
for later retrieval by the HealthConnectProvider.
"""
logger.info(f"=== Health Connect Sync Request from user {current_user.username} ===")
logger.info(f"Device ID: {request.device_id}")
logger.info(f"Timestamp: {request.sync_timestamp}")
logger.info(f"Daily metrics count: {len(request.daily_metrics)}")
logger.info(f"Exercise sessions count: {len(request.exercise_sessions)}")
# Log sample data for debugging
if request.daily_metrics:
sample = request.daily_metrics[0]
logger.info(f"Sample metric - Date: {sample.date}, Steps: {sample.step_count}, Calories: {sample.calories_burned}")
if request.exercise_sessions:
sample = request.exercise_sessions[0]
logger.info(f"Sample exercise - Type: {sample.exercise_type_name}, Duration: {sample.duration_seconds}s")
# Create sync log entry
sync_log = HealthConnectSyncLog(
user_id=current_user.id,
device_id=request.device_id,
records_pushed=len(request.daily_metrics) + len(request.exercise_sessions),
status="processing"
)
db.add(sync_log)
db.flush() # Get the sync_log.id
metrics_synced = 0
exercises_synced = 0
try:
# Process daily metrics
for metrics in request.daily_metrics:
# Upsert: update if exists, insert if not
existing = db.query(HealthConnectDailyMetrics).filter(
HealthConnectDailyMetrics.user_id == current_user.id,
HealthConnectDailyMetrics.date == metrics.date
).first()
if existing:
# Update existing record
for field, value in metrics.model_dump().items():
if value is not None:
setattr(existing, field, value)
existing.last_synced = datetime.utcnow()
existing.sync_id = sync_log.id
logger.info(f"Updated existing metric for date {metrics.date}")
else:
# Create new record
new_metrics = HealthConnectDailyMetrics(
user_id=current_user.id,
sync_id=sync_log.id,
**metrics.model_dump()
)
db.add(new_metrics)
logger.info(f"Created new metric for date {metrics.date}")
metrics_synced += 1
# --- AUTO-SYNC to Main DailyHealthMetrics Table ---
try:
main_existing = db.query(DailyHealthMetrics).filter(
DailyHealthMetrics.user_id == current_user.id,
DailyHealthMetrics.date == metrics.date
).first()
# Filter fields that exist in DailyHealthMetrics
metric_data = metrics.model_dump()
# Manual Mapping / Cleanup
# DailyMetricsPayload has snake_case keys which match DailyHealthMetrics mostly
# Exceptions:
# - DailyHealthMetrics has no 'data_source_name'
# - DailyHealthMetrics has 'data_source' (string)
update_data = {}
for key, value in metric_data.items():
if value is not None and hasattr(DailyHealthMetrics, key):
update_data[key] = value
# Explicitly set data source
update_data["data_source"] = "health_connect"
if main_existing:
# Update if value is not None (and maybe preferential logic?)
# For now, we trust the push as latest source of truth
for key, value in update_data.items():
setattr(main_existing, key, value)
logger.info(f"Auto-synced main health metric for {metrics.date}")
else:
new_main = DailyHealthMetrics(
user_id=current_user.id,
**update_data
)
db.add(new_main)
logger.info(f"Created main health metric for {metrics.date}")
except Exception as e:
logger.error(f"Failed to auto-sync daily metric to main table: {e}")
# Don't fail the request, just log
# Process exercise sessions
for session in request.exercise_sessions:
# Dedup by start_time
existing = db.query(HealthConnectExerciseSession).filter(
HealthConnectExerciseSession.user_id == current_user.id,
HealthConnectExerciseSession.start_time == session.start_time
).first()
if not existing:
# Get exercise type name if not provided
exercise_name = session.exercise_type_name or HEALTH_CONNECT_EXERCISE_TYPES.get(
session.exercise_type, f"Exercise {session.exercise_type}"
)
new_session = HealthConnectExerciseSession(
user_id=current_user.id,
sync_id=sync_log.id,
exercise_type_name=exercise_name,
**session.model_dump(exclude={"exercise_type_name"})
)
db.add(new_session)
exercises_synced += 1
logger.info(f"Created new exercise session: {exercise_name}")
# --- AUTO-SYNC to Main WorkoutSession Table ---
try:
# Check existence in main table (simple check by start time)
main_workout_exists = db.query(WorkoutSession).filter(
WorkoutSession.user_id == current_user.id,
WorkoutSession.start_time == session.start_time
).first()
if not main_workout_exists:
# key mapping
sess_data = session.model_dump(exclude={"exercise_type_name"})
# WorkoutSession fields:
# start_time, end_time, activity_type (str), activity_type_id (int),
# duration_seconds, distance_meters, calories (not calories_burned),
# avg_hr, max_hr, min_hr, notes
new_workout = WorkoutSession(
user_id=current_user.id,
start_time=session.start_time,
end_time=session.end_time,
activity_type=exercise_name,
activity_type_id=session.exercise_type,
duration_seconds=session.duration_seconds,
distance_meters=session.distance_meters or 0,
calories=session.calories_burned or 0,
avg_hr=session.avg_heart_rate,
max_hr=session.max_heart_rate,
min_hr=session.min_heart_rate,
notes=session.notes,
data_source="health_connect"
)
db.add(new_workout)
logger.info(f"Auto-synced workout session: {exercise_name}")
except Exception as e:
logger.error(f"Failed to auto-sync workout to main table: {e}")
else:
logger.info(f"Skipped duplicate exercise session at {session.start_time}")
# Update sync log
sync_log.status = "success"
sync_log.records_pushed = metrics_synced + exercises_synced
db.commit()
logger.info(f"=== Sync Complete: {metrics_synced} metrics, {exercises_synced} exercises ===")
return SyncResponse(
status="success",
sync_id=sync_log.id,
metrics_synced=metrics_synced,
exercises_synced=exercises_synced,
message=f"Successfully synced {metrics_synced} daily records and {exercises_synced} exercise sessions"
)
except Exception as e:
logger.error(f"Sync failed: {str(e)}", exc_info=True)
sync_log.status = "error"
sync_log.error_message = str(e)
db.commit()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Sync failed: {str(e)}"
)
@router.get("/status", status_code=status.HTTP_200_OK)
async def get_sync_status(
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get the status of Health Connect sync for current user."""
# Get latest sync
latest_sync = db.query(HealthConnectSyncLog).filter(
HealthConnectSyncLog.user_id == current_user.id
).order_by(HealthConnectSyncLog.sync_timestamp.desc()).first()
# Count records
metrics_count = db.query(HealthConnectDailyMetrics).filter(
HealthConnectDailyMetrics.user_id == current_user.id
).count()
sessions_count = db.query(HealthConnectExerciseSession).filter(
HealthConnectExerciseSession.user_id == current_user.id
).count()
return {
"has_data": metrics_count > 0 or sessions_count > 0,
"metrics_count": metrics_count,
"sessions_count": sessions_count,
"last_sync": latest_sync.sync_timestamp if latest_sync else None,
"last_sync_status": latest_sync.status if latest_sync else None,
"last_device_id": latest_sync.device_id if latest_sync else None
}
@router.get("/metrics", status_code=status.HTTP_200_OK)
async def get_health_connect_metrics(
days: int = 30,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get Health Connect daily metrics for current user."""
from datetime import timedelta
start_date = date.today() - timedelta(days=days)
metrics = db.query(HealthConnectDailyMetrics).filter(
HealthConnectDailyMetrics.user_id == current_user.id,
HealthConnectDailyMetrics.date >= start_date
).order_by(HealthConnectDailyMetrics.date.desc()).all()
return [
{
"date": m.date.isoformat(),
"step_count": m.step_count,
"distance_meters": m.distance_meters,
"calories_burned": m.calories_burned,
"sleep_duration_minutes": m.sleep_duration_minutes,
"deep_sleep_minutes": m.deep_sleep_minutes,
"light_sleep_minutes": m.light_sleep_minutes,
"rem_sleep_minutes": m.rem_sleep_minutes,
"avg_heart_rate": m.avg_heart_rate,
"avg_spo2": m.avg_spo2,
"weight": m.weight,
"data_source": m.data_source,
"data_source_name": m.data_source_name
}
for m in metrics
]
+734
View File
@@ -0,0 +1,734 @@
"""
Health Connect Raw Data API - Endpoints for receiving raw health records from Android.
This endpoint receives individual health records (not aggregated) from the Android
companion app and stores them for accurate on-demand aggregation.
OPTIMIZED VERSION: Uses batch inserts and efficient duplicate detection.
"""
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session
from sqlalchemy import and_, tuple_
from typing import List, Optional, Set, Tuple
from pydantic import BaseModel
from datetime import datetime
import logging
import time
logger = logging.getLogger(__name__)
from backend.database import get_db
from backend.models.user import User
from backend.models.health_connect import HealthConnectSyncLog
from backend.models.health_connect_raw import (
HCRawSteps,
HCRawHeartRate,
HCRawHeartRateSample,
HCRawRestingHeartRate,
HCRawSleepSession,
HCRawSleepStage,
HCRawDistance,
HCRawCalories,
HCRawOxygenSaturation,
HCRawWeight,
HCRawHeight,
HCRawBodyFat,
HCRawExerciseSession,
HCRawNutrition,
HCRawHydration,
SLEEP_STAGE_TYPES,
)
from backend import auth
router = APIRouter(prefix="/api/health-connect", tags=["health-connect-raw"])
# ==================== REQUEST MODELS ====================
class RawStepsPayload(BaseModel):
"""Individual step record from Health Connect"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
count: int
class HeartRateSamplePayload(BaseModel):
"""Single heart rate sample"""
time: datetime
bpm: int
class RawHeartRatePayload(BaseModel):
"""Heart rate record with samples"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
samples: List[HeartRateSamplePayload] = []
class RawRestingHeartRatePayload(BaseModel):
"""Resting heart rate record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
time: datetime
bpm: int
class SleepStagePayload(BaseModel):
"""Sleep stage within a session"""
start_time: datetime
end_time: datetime
stage_type: int
stage_name: Optional[str] = None
class RawSleepSessionPayload(BaseModel):
"""Sleep session with stages"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
title: Optional[str] = None
notes: Optional[str] = None
stages: List[SleepStagePayload] = []
class RawDistancePayload(BaseModel):
"""Distance record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
distance_meters: float
class RawCaloriesPayload(BaseModel):
"""Calories record (active or total)"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
calories: float
record_type: str # "active" or "total"
class RawOxygenSaturationPayload(BaseModel):
"""SpO2 record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
time: datetime
percentage: float
class RawWeightPayload(BaseModel):
"""Weight record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
time: datetime
weight_kg: float
class RawHeightPayload(BaseModel):
"""Height record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
time: datetime
height_meters: float
class RawBodyFatPayload(BaseModel):
"""Body fat record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
time: datetime
percentage: float
class RawExerciseSessionPayload(BaseModel):
"""Exercise session"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
exercise_type: int
exercise_type_name: Optional[str] = None
title: Optional[str] = None
notes: Optional[str] = None
distance_meters: Optional[float] = None
calories: Optional[float] = None
avg_heart_rate: Optional[int] = None
max_heart_rate: Optional[int] = None
min_heart_rate: Optional[int] = None
steps: Optional[int] = None
elevation_gained_meters: Optional[float] = None
class RawNutritionPayload(BaseModel):
"""Nutrition record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
name: Optional[str] = None
meal_type: Optional[int] = None
calories: Optional[float] = None
protein_grams: Optional[float] = None
carbohydrates_grams: Optional[float] = None
fat_grams: Optional[float] = None
fiber_grams: Optional[float] = None
sugar_grams: Optional[float] = None
class RawHydrationPayload(BaseModel):
"""Hydration record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
volume_liters: float
class RawDataSyncRequest(BaseModel):
"""Complete raw data sync request from Android app"""
device_id: str
sync_timestamp: str = ""
# All record types
steps_records: List[RawStepsPayload] = []
heart_rate_records: List[RawHeartRatePayload] = []
resting_hr_records: List[RawRestingHeartRatePayload] = []
sleep_sessions: List[RawSleepSessionPayload] = []
distance_records: List[RawDistancePayload] = []
calories_records: List[RawCaloriesPayload] = []
spo2_records: List[RawOxygenSaturationPayload] = []
weight_records: List[RawWeightPayload] = []
height_records: List[RawHeightPayload] = []
body_fat_records: List[RawBodyFatPayload] = []
exercise_sessions: List[RawExerciseSessionPayload] = []
nutrition_records: List[RawNutritionPayload] = []
hydration_records: List[RawHydrationPayload] = []
class RawSyncResponse(BaseModel):
"""Response for raw data sync"""
status: str
sync_id: int
records_synced: dict # Count per record type
message: str
# ==================== OPTIMIZED HELPER FUNCTIONS ====================
def get_existing_record_ids(db: Session, model, user_id: int, record_ids: List[str]) -> Set[str]:
"""
Batch query to find which record_ids already exist for the user.
Returns a set of existing record_ids.
"""
if not record_ids:
return set()
# Process in batches to avoid query limits
batch_size = 500
existing_ids = set()
for i in range(0, len(record_ids), batch_size):
batch = record_ids[i:i + batch_size]
results = db.query(model.record_id).filter(
model.user_id == user_id,
model.record_id.in_(batch)
).all()
existing_ids.update(r[0] for r in results)
return existing_ids
def batch_insert_simple_records(db: Session, model, user_id: int, records: list,
sync_id: int, existing_ids: Set[str]) -> int:
"""
Batch insert records, skipping those that already exist.
Also deduplicates records within the payload itself.
Returns count of new records inserted.
"""
new_records = []
now = datetime.utcnow()
seen_ids = set() # Track record_ids we've already processed in this batch
for record in records:
# Skip if already exists in database
if record.record_id in existing_ids:
continue
# Skip if we've already seen this record_id in this payload (dedupe)
if record.record_id in seen_ids:
continue
seen_ids.add(record.record_id)
data = record.model_dump()
data['user_id'] = user_id
data['sync_id'] = sync_id
data['synced_at'] = now
new_records.append(model(**data))
if new_records:
db.bulk_save_objects(new_records)
return len(new_records)
# ==================== ENDPOINTS ====================
@router.post("/sync-raw", response_model=RawSyncResponse, status_code=status.HTTP_200_OK)
async def receive_raw_health_data(
request: RawDataSyncRequest,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Receive raw health records from Android companion app.
OPTIMIZED: Uses batch queries and bulk inserts for much better performance.
"""
start_time = time.time()
# Count total records (excluding HR samples for now)
total_records = (
len(request.steps_records) +
len(request.heart_rate_records) +
len(request.resting_hr_records) +
len(request.sleep_sessions) +
len(request.distance_records) +
len(request.calories_records) +
len(request.spo2_records) +
len(request.weight_records) +
len(request.height_records) +
len(request.body_fat_records) +
len(request.exercise_sessions) +
len(request.nutrition_records) +
len(request.hydration_records)
)
# Count HR samples
total_hr_samples = sum(len(hr.samples) for hr in request.heart_rate_records)
logger.info(f"=== Raw Data Sync from user {current_user.username} ===")
logger.info(f"Device: {request.device_id}, Records: {total_records}, HR Samples: {total_hr_samples}")
# Create sync log
sync_log = HealthConnectSyncLog(
user_id=current_user.id,
device_id=request.device_id,
records_pushed=total_records,
status="processing"
)
db.add(sync_log)
db.flush()
# Track counts
counts = {
"steps": 0,
"heart_rate": 0,
"heart_rate_samples": 0,
"resting_hr": 0,
"sleep_sessions": 0,
"sleep_stages": 0,
"distance": 0,
"calories": 0,
"spo2": 0,
"weight": 0,
"height": 0,
"body_fat": 0,
"exercise": 0,
"nutrition": 0,
"hydration": 0,
}
try:
now = datetime.utcnow()
# === STEPS ===
if request.steps_records:
step_ids = [r.record_id for r in request.steps_records]
existing_step_ids = get_existing_record_ids(db, HCRawSteps, current_user.id, step_ids)
counts["steps"] = batch_insert_simple_records(
db, HCRawSteps, current_user.id, request.steps_records,
sync_log.id, existing_step_ids
)
logger.info(f"Steps: {counts['steps']} new of {len(request.steps_records)} total")
# === DISTANCE ===
if request.distance_records:
dist_ids = [r.record_id for r in request.distance_records]
existing_dist_ids = get_existing_record_ids(db, HCRawDistance, current_user.id, dist_ids)
counts["distance"] = batch_insert_simple_records(
db, HCRawDistance, current_user.id, request.distance_records,
sync_log.id, existing_dist_ids
)
logger.info(f"Distance: {counts['distance']} new of {len(request.distance_records)} total")
# === CALORIES ===
if request.calories_records:
cal_ids = [r.record_id for r in request.calories_records]
existing_cal_ids = get_existing_record_ids(db, HCRawCalories, current_user.id, cal_ids)
counts["calories"] = batch_insert_simple_records(
db, HCRawCalories, current_user.id, request.calories_records,
sync_log.id, existing_cal_ids
)
logger.info(f"Calories: {counts['calories']} new of {len(request.calories_records)} total")
# === RESTING HR ===
if request.resting_hr_records:
rhr_ids = [r.record_id for r in request.resting_hr_records]
existing_rhr_ids = get_existing_record_ids(db, HCRawRestingHeartRate, current_user.id, rhr_ids)
counts["resting_hr"] = batch_insert_simple_records(
db, HCRawRestingHeartRate, current_user.id, request.resting_hr_records,
sync_log.id, existing_rhr_ids
)
logger.info(f"Resting HR: {counts['resting_hr']} new of {len(request.resting_hr_records)} total")
# === SpO2 ===
if request.spo2_records:
spo2_ids = [r.record_id for r in request.spo2_records]
existing_spo2_ids = get_existing_record_ids(db, HCRawOxygenSaturation, current_user.id, spo2_ids)
counts["spo2"] = batch_insert_simple_records(
db, HCRawOxygenSaturation, current_user.id, request.spo2_records,
sync_log.id, existing_spo2_ids
)
logger.info(f"SpO2: {counts['spo2']} new of {len(request.spo2_records)} total")
# === WEIGHT ===
if request.weight_records:
weight_ids = [r.record_id for r in request.weight_records]
existing_weight_ids = get_existing_record_ids(db, HCRawWeight, current_user.id, weight_ids)
counts["weight"] = batch_insert_simple_records(
db, HCRawWeight, current_user.id, request.weight_records,
sync_log.id, existing_weight_ids
)
logger.info(f"Weight: {counts['weight']} new of {len(request.weight_records)} total")
# === HEIGHT ===
if request.height_records:
height_ids = [r.record_id for r in request.height_records]
existing_height_ids = get_existing_record_ids(db, HCRawHeight, current_user.id, height_ids)
counts["height"] = batch_insert_simple_records(
db, HCRawHeight, current_user.id, request.height_records,
sync_log.id, existing_height_ids
)
logger.info(f"Height: {counts['height']} new of {len(request.height_records)} total")
# === BODY FAT ===
if request.body_fat_records:
bf_ids = [r.record_id for r in request.body_fat_records]
existing_bf_ids = get_existing_record_ids(db, HCRawBodyFat, current_user.id, bf_ids)
counts["body_fat"] = batch_insert_simple_records(
db, HCRawBodyFat, current_user.id, request.body_fat_records,
sync_log.id, existing_bf_ids
)
logger.info(f"Body Fat: {counts['body_fat']} new of {len(request.body_fat_records)} total")
# === NUTRITION ===
if request.nutrition_records:
nut_ids = [r.record_id for r in request.nutrition_records]
existing_nut_ids = get_existing_record_ids(db, HCRawNutrition, current_user.id, nut_ids)
counts["nutrition"] = batch_insert_simple_records(
db, HCRawNutrition, current_user.id, request.nutrition_records,
sync_log.id, existing_nut_ids
)
logger.info(f"Nutrition: {counts['nutrition']} new of {len(request.nutrition_records)} total")
# === HYDRATION ===
if request.hydration_records:
hyd_ids = [r.record_id for r in request.hydration_records]
existing_hyd_ids = get_existing_record_ids(db, HCRawHydration, current_user.id, hyd_ids)
counts["hydration"] = batch_insert_simple_records(
db, HCRawHydration, current_user.id, request.hydration_records,
sync_log.id, existing_hyd_ids
)
logger.info(f"Hydration: {counts['hydration']} new of {len(request.hydration_records)} total")
# === EXERCISE SESSIONS ===
if request.exercise_sessions:
ex_ids = [r.record_id for r in request.exercise_sessions]
existing_ex_ids = get_existing_record_ids(db, HCRawExerciseSession, current_user.id, ex_ids)
counts["exercise"] = batch_insert_simple_records(
db, HCRawExerciseSession, current_user.id, request.exercise_sessions,
sync_log.id, existing_ex_ids
)
logger.info(f"Exercise: {counts['exercise']} new of {len(request.exercise_sessions)} total")
# === HEART RATE (with samples - needs special handling) ===
if request.heart_rate_records:
hr_ids = [r.record_id for r in request.heart_rate_records]
existing_hr_ids = get_existing_record_ids(db, HCRawHeartRate, current_user.id, hr_ids)
# Insert new HR records
new_hr_records = []
hr_records_for_samples = [] # Track which records need samples
for record in request.heart_rate_records:
if record.record_id in existing_hr_ids:
continue
data = record.model_dump(exclude={"samples"})
data['user_id'] = current_user.id
data['sync_id'] = sync_log.id
data['synced_at'] = now
hr_obj = HCRawHeartRate(**data)
new_hr_records.append(hr_obj)
hr_records_for_samples.append((hr_obj, record.samples))
if new_hr_records:
db.bulk_save_objects(new_hr_records, return_defaults=True)
db.flush() # Get IDs
counts["heart_rate"] = len(new_hr_records)
# Now insert samples in batches
all_samples = []
for hr_obj, samples in hr_records_for_samples:
for sample in samples:
all_samples.append(HCRawHeartRateSample(
record_id=hr_obj.id,
time=sample.time,
bpm=sample.bpm
))
if all_samples:
# Insert in batches of 1000
batch_size = 1000
for i in range(0, len(all_samples), batch_size):
batch = all_samples[i:i + batch_size]
db.bulk_save_objects(batch)
counts["heart_rate_samples"] = len(all_samples)
logger.info(f"Heart Rate: {counts['heart_rate']} records, {counts['heart_rate_samples']} samples")
# === SLEEP SESSIONS (with stages) ===
if request.sleep_sessions:
sleep_ids = [r.record_id for r in request.sleep_sessions]
existing_sleep_ids = get_existing_record_ids(db, HCRawSleepSession, current_user.id, sleep_ids)
new_sleep_records = []
sleep_records_for_stages = []
for record in request.sleep_sessions:
if record.record_id in existing_sleep_ids:
continue
data = record.model_dump(exclude={"stages"})
data['user_id'] = current_user.id
data['sync_id'] = sync_log.id
data['synced_at'] = now
sleep_obj = HCRawSleepSession(**data)
new_sleep_records.append(sleep_obj)
sleep_records_for_stages.append((sleep_obj, record.stages))
if new_sleep_records:
db.bulk_save_objects(new_sleep_records, return_defaults=True)
db.flush()
counts["sleep_sessions"] = len(new_sleep_records)
# Insert stages
all_stages = []
for sleep_obj, stages in sleep_records_for_stages:
for stage in stages:
stage_name = stage.stage_name or SLEEP_STAGE_TYPES.get(stage.stage_type, "unknown")
all_stages.append(HCRawSleepStage(
session_id=sleep_obj.id,
start_time=stage.start_time,
end_time=stage.end_time,
stage_type=stage.stage_type,
stage_name=stage_name
))
if all_stages:
db.bulk_save_objects(all_stages)
counts["sleep_stages"] = len(all_stages)
logger.info(f"Sleep: {counts['sleep_sessions']} sessions, {counts['sleep_stages']} stages")
# Update sync log
sync_log.status = "success"
sync_log.records_pushed = sum(counts.values())
db.commit()
elapsed = time.time() - start_time
logger.info(f"=== Raw Sync Complete in {elapsed:.2f}s ===")
logger.info(f"Records synced: {counts}")
return RawSyncResponse(
status="success",
sync_id=sync_log.id,
records_synced=counts,
message=f"Successfully synced {sum(counts.values())} raw records in {elapsed:.2f}s"
)
except Exception as e:
logger.error(f"Raw sync failed: {str(e)}", exc_info=True)
db.rollback()
sync_log.status = "error"
sync_log.error_message = str(e)[:500] # Limit error message length
db.commit()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Raw sync failed: {str(e)}"
)
@router.get("/raw/steps", status_code=status.HTTP_200_OK)
async def get_raw_steps(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
limit: int = 1000,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get raw step records for a date range."""
from datetime import timedelta
if not end_date:
end_date = datetime.utcnow()
if not start_date:
start_date = end_date - timedelta(days=7)
records = db.query(HCRawSteps).filter(
HCRawSteps.user_id == current_user.id,
HCRawSteps.start_time >= start_date,
HCRawSteps.start_time <= end_date
).order_by(HCRawSteps.start_time.desc()).limit(limit).all()
return [
{
"id": r.id,
"record_id": r.record_id,
"data_source": r.data_source,
"data_source_name": r.data_source_name,
"start_time": r.start_time.isoformat() if r.start_time else None,
"end_time": r.end_time.isoformat() if r.end_time else None,
"count": r.count
}
for r in records
]
@router.get("/raw/heart-rate", status_code=status.HTTP_200_OK)
async def get_raw_heart_rate(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
include_samples: bool = False,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get raw heart rate records for a date range."""
from datetime import timedelta
if not end_date:
end_date = datetime.utcnow()
if not start_date:
start_date = end_date - timedelta(days=1)
records = db.query(HCRawHeartRate).filter(
HCRawHeartRate.user_id == current_user.id,
HCRawHeartRate.start_time >= start_date,
HCRawHeartRate.start_time <= end_date
).order_by(HCRawHeartRate.start_time.desc()).limit(limit).all()
result = []
for r in records:
record_dict = {
"id": r.id,
"record_id": r.record_id,
"data_source": r.data_source,
"start_time": r.start_time.isoformat() if r.start_time else None,
"end_time": r.end_time.isoformat() if r.end_time else None,
}
if include_samples:
record_dict["samples"] = [
{"time": s.time.isoformat(), "bpm": s.bpm}
for s in r.samples
]
else:
record_dict["sample_count"] = len(r.samples)
result.append(record_dict)
return result
@router.get("/raw/stats", status_code=status.HTTP_200_OK)
async def get_raw_data_stats(
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get statistics about raw data stored for current user."""
from sqlalchemy import func
stats = {
"steps": db.query(func.count(HCRawSteps.id)).filter(
HCRawSteps.user_id == current_user.id
).scalar(),
"heart_rate": db.query(func.count(HCRawHeartRate.id)).filter(
HCRawHeartRate.user_id == current_user.id
).scalar(),
"heart_rate_samples": db.query(func.count(HCRawHeartRateSample.id)).join(
HCRawHeartRate
).filter(
HCRawHeartRate.user_id == current_user.id
).scalar(),
"resting_hr": db.query(func.count(HCRawRestingHeartRate.id)).filter(
HCRawRestingHeartRate.user_id == current_user.id
).scalar(),
"sleep_sessions": db.query(func.count(HCRawSleepSession.id)).filter(
HCRawSleepSession.user_id == current_user.id
).scalar(),
"distance": db.query(func.count(HCRawDistance.id)).filter(
HCRawDistance.user_id == current_user.id
).scalar(),
"calories": db.query(func.count(HCRawCalories.id)).filter(
HCRawCalories.user_id == current_user.id
).scalar(),
"spo2": db.query(func.count(HCRawOxygenSaturation.id)).filter(
HCRawOxygenSaturation.user_id == current_user.id
).scalar(),
"weight": db.query(func.count(HCRawWeight.id)).filter(
HCRawWeight.user_id == current_user.id
).scalar(),
"exercise": db.query(func.count(HCRawExerciseSession.id)).filter(
HCRawExerciseSession.user_id == current_user.id
).scalar(),
}
# Get date range
oldest_step = db.query(func.min(HCRawSteps.start_time)).filter(
HCRawSteps.user_id == current_user.id
).scalar()
newest_step = db.query(func.max(HCRawSteps.start_time)).filter(
HCRawSteps.user_id == current_user.id
).scalar()
return {
"record_counts": stats,
"total_records": sum(stats.values()),
"date_range": {
"oldest": oldest_step.isoformat() if oldest_step else None,
"newest": newest_step.isoformat() if newest_step else None
}
}