Add initial project
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""API module for HabitForge."""
|
||||
@@ -0,0 +1 @@
|
||||
"""API v1 module for HabitForge."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
]
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user