Add initial project
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
Haut du corps (Bras, Épaules, Pectoraux, Dos)
|
||||
faire l'image pour "Dips" en conservant la fille et l'environnement
|
||||
faire l'image pour "Diamond Push-ups" en conservant la fille et l'environnement
|
||||
faire l'image pour "Pike Push-ups" en conservant la fille et l'environnement
|
||||
faire l'image pour "Plank to Push-up" en conservant la fille et l'environnement
|
||||
faire l'image pour "Chin-ups" en conservant la fille et l'environnement
|
||||
faire une image pour "Inverted Rows" en conservant la fille et l'environnement
|
||||
faire une image pour "Superman" en conservant la fille et l'environnement
|
||||
|
||||
Bas du corps (Jambes, Fessiers)
|
||||
faire une image pour "Lunges (Fentes)" en conservant la fille et l'environnement
|
||||
faire une image pour "Glute Bridges" en conservant la fille et l'environnement
|
||||
faire une image pour "Wall Sit" en conservant la fille et l'environnement
|
||||
faire une image pour "Calf Raises" en conservant la fille et l'environnement
|
||||
faire une image pour "Jump Squats" en conservant la fille et l'environnement
|
||||
|
||||
Abdominaux et Gainage
|
||||
faire une image pour "Plank" en conservant la fille et l'environnement
|
||||
faire une image pour "Russian Twists" en conservant la fille et l'environnement
|
||||
faire une image pour "Leg Raises" en conservant la fille et l'environnement
|
||||
faire une image pour "Mountain Climbers" en conservant la fille et l'environnement
|
||||
basé sur une image créée par un AI, faire une image pour une exercise de "Bicycle Crunches" en conservant la fille et l'environnement
|
||||
|
||||
Cardio et Corps complet
|
||||
faire une image pour "Burpees" en conservant la fille et l'environnement
|
||||
faire une image pour "Jumping Jacks" en conservant la fille et l'environnement
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Backend module for HabitForge."""
|
||||
from backend.models import User, Challenge, Tracking, DailyHealthMetrics
|
||||
from backend.schemas import (
|
||||
UserCreate, UserResponse,
|
||||
ChallengeBase, ChallengeCreate, ChallengeResponse,
|
||||
TrackingBase, TrackingCreate, TrackingResponse,
|
||||
DailyHealthMetricsBase, DailyHealthMetricsResponse,
|
||||
Token
|
||||
)
|
||||
from backend import auth, database
|
||||
|
||||
__all__ = [
|
||||
# Models
|
||||
"User", "Challenge", "Tracking", "DailyHealthMetrics",
|
||||
# Schemas
|
||||
"UserCreate", "UserResponse",
|
||||
"ChallengeBase", "ChallengeCreate", "ChallengeResponse",
|
||||
"TrackingBase", "TrackingCreate", "TrackingResponse",
|
||||
"DailyHealthMetricsBase", "DailyHealthMetricsResponse",
|
||||
"Token",
|
||||
# Modules
|
||||
"auth", "database"
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from .core.config import settings
|
||||
from .database import get_db
|
||||
from .models.user import User
|
||||
|
||||
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def get_password_hash(password):
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=15)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def create_refresh_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
|
||||
|
||||
|
||||
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
|
||||
"""Get the current authenticated user from JWT token."""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
username: Optional[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
|
||||
return user
|
||||
@@ -0,0 +1 @@
|
||||
"""Core module for configuration and security."""
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Configuration module using pydantic_settings for environment-based configuration."""
|
||||
from secrets import token_urlsafe
|
||||
from typing import List, Union
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
# Security
|
||||
SECRET_KEY: str = "dev_secret_key_fixed_for_persistence_change_in_prod"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_MINUTES: int = 43200 # 30 days
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "sqlite:///./data/habitforge.db"
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: Union[List[str], str] = ["http://localhost:8000", "http://localhost:3000"]
|
||||
CORS_ALLOW_CREDENTIALS: bool = True
|
||||
CORS_ALLOW_METHODS: Union[List[str], str] = ["*"]
|
||||
CORS_ALLOW_HEADERS: Union[List[str], str] = ["*"]
|
||||
|
||||
# Google OAuth
|
||||
GOOGLE_CLIENT_ID: str = ""
|
||||
GOOGLE_CLIENT_SECRET: str = ""
|
||||
GOOGLE_REDIRECT_URI: str = "http://localhost:8000/api/google/callback"
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_ENABLED: bool = True
|
||||
RATE_LIMIT_TIMES: int = 60
|
||||
RATE_LIMIT_SECONDS: int = 60
|
||||
|
||||
# Application
|
||||
APP_NAME: str = "HabitForge API"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = True
|
||||
|
||||
# Health Data Provider Selection
|
||||
# Options: "health_connect"
|
||||
HEALTH_PROVIDER: str = "health_connect"
|
||||
|
||||
@field_validator("CORS_ORIGINS", "CORS_ALLOW_METHODS", "CORS_ALLOW_HEADERS", mode="before")
|
||||
@classmethod
|
||||
def assemble_cors_list(cls, v: Union[str, List[str]]) -> List[str]:
|
||||
"""Convert a comma-separated string into a list of strings if necessary."""
|
||||
if isinstance(v, str) and not v.startswith("["):
|
||||
return [i.strip() for i in v.split(",") if i.strip()]
|
||||
return v
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True,
|
||||
extra="ignore"
|
||||
)
|
||||
|
||||
|
||||
# Global settings instance
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,29 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import os
|
||||
from .core.config import settings
|
||||
|
||||
# Ensure data directory exists for SQLite
|
||||
if settings.DATABASE_URL.startswith("sqlite"):
|
||||
os.makedirs("./data", exist_ok=True)
|
||||
|
||||
# Create engine with appropriate settings based on database type
|
||||
if settings.DATABASE_URL.startswith("sqlite"):
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
connect_args={"check_same_thread": False}
|
||||
)
|
||||
else:
|
||||
engine = create_engine(settings.DATABASE_URL)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Integrations module for external APIs."""
|
||||
from backend.integrations.health_provider import (
|
||||
HealthDataProvider,
|
||||
DataSource,
|
||||
HealthProviderFactory
|
||||
)
|
||||
|
||||
# Import providers to register them with the factory
|
||||
# from backend.integrations import google_fit_provider
|
||||
from backend.integrations import health_connect_provider
|
||||
|
||||
__all__ = [
|
||||
"HealthDataProvider",
|
||||
"DataSource",
|
||||
"HealthProviderFactory",
|
||||
]
|
||||
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Health Connect Provider - Implementation for Health Connect API.
|
||||
|
||||
This module provides the Health Connect implementation that reads data
|
||||
pushed from the Android companion app via the /api/health-connect/sync endpoint.
|
||||
"""
|
||||
import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from backend.integrations.health_provider import (
|
||||
HealthDataProvider,
|
||||
DataSource,
|
||||
HealthProviderFactory
|
||||
)
|
||||
|
||||
|
||||
class HealthConnectProvider(HealthDataProvider):
|
||||
"""
|
||||
Health Connect implementation of HealthDataProvider.
|
||||
|
||||
This provider reads data that has been pushed from an Android companion app
|
||||
via the /api/health-connect/sync endpoint and stored in the database.
|
||||
"""
|
||||
|
||||
@property
|
||||
def source(self) -> DataSource:
|
||||
return DataSource.HEALTH_CONNECT
|
||||
|
||||
def get_auth_url(self) -> str:
|
||||
"""
|
||||
Health Connect doesn't use OAuth - permissions are granted on-device.
|
||||
|
||||
Returns a URL to download the Android companion app.
|
||||
"""
|
||||
# TODO: Replace with actual Play Store URL when app is published
|
||||
return "https://play.google.com/store/apps/details?id=com.habitforge.companion"
|
||||
|
||||
async def fetch_daily_metrics(
|
||||
self,
|
||||
access_token: Optional[str], # Not used for Health Connect, but required by interface
|
||||
days: int = 7,
|
||||
timezone: str = "UTC",
|
||||
user_id: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch daily metrics from Health Connect data store.
|
||||
|
||||
Reads data that was pushed from the Android companion app.
|
||||
"""
|
||||
if not user_id:
|
||||
print("[HealthConnect] ERROR: user_id is required")
|
||||
return []
|
||||
|
||||
print(f"[HealthConnect] Fetching daily metrics for user {user_id}, last {days} days")
|
||||
|
||||
from backend.database import SessionLocal
|
||||
from backend.models.health_connect import HealthConnectDailyMetrics
|
||||
from datetime import timedelta, date as date_type
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
start_date = date_type.today() - timedelta(days=days)
|
||||
|
||||
metrics = db.query(HealthConnectDailyMetrics).filter(
|
||||
HealthConnectDailyMetrics.user_id == user_id,
|
||||
HealthConnectDailyMetrics.date >= start_date
|
||||
).order_by(HealthConnectDailyMetrics.date.desc()).all()
|
||||
|
||||
if not metrics:
|
||||
print(f"[HealthConnect] No data found. Ensure Android app has synced data.")
|
||||
return []
|
||||
|
||||
print(f"[HealthConnect] Found {len(metrics)} days of data")
|
||||
|
||||
# Convert to standard format
|
||||
return [
|
||||
{
|
||||
"date": m.date,
|
||||
"step_count": m.step_count or 0,
|
||||
"calories_burned": m.calories_burned or 0.0,
|
||||
"distance_meters": m.distance_meters or 0.0,
|
||||
"sleep_duration_minutes": m.sleep_duration_minutes or 0,
|
||||
"deep_sleep_minutes": m.deep_sleep_minutes or 0,
|
||||
"light_sleep_minutes": m.light_sleep_minutes or 0,
|
||||
"rem_sleep_minutes": m.rem_sleep_minutes or 0,
|
||||
"awake_duration_minutes": m.awake_duration_minutes or 0,
|
||||
"avg_heart_rate": m.avg_heart_rate or 0,
|
||||
"min_heart_rate": m.min_heart_rate or 0,
|
||||
"max_heart_rate": m.max_heart_rate or 0,
|
||||
"avg_spo2": m.avg_spo2 or 0.0,
|
||||
"pai_score": 0.0, # Calculate if needed
|
||||
"weight": m.weight,
|
||||
"resting_heart_rate": m.resting_heart_rate,
|
||||
"hrv": None
|
||||
}
|
||||
for m in metrics
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def fetch_workouts(
|
||||
self,
|
||||
access_token: Optional[str],
|
||||
days: int = 30
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch exercise sessions from Health Connect data store.
|
||||
"""
|
||||
# Note: We need user_id but the interface doesn't pass it for workouts
|
||||
# This is a limitation we'll need to address in the interface
|
||||
print(f"[HealthConnect] fetch_workouts called")
|
||||
|
||||
from backend.database import SessionLocal
|
||||
from backend.models.health_connect import HealthConnectExerciseSession
|
||||
from datetime import timedelta
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
start_date = datetime.datetime.now() - timedelta(days=days)
|
||||
|
||||
# Query all sessions (we can't filter by user without user_id)
|
||||
# In practice, this would be called with user context
|
||||
sessions = db.query(HealthConnectExerciseSession).filter(
|
||||
HealthConnectExerciseSession.start_time >= start_date
|
||||
).order_by(HealthConnectExerciseSession.start_time.desc()).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"start_time": s.start_time,
|
||||
"end_time": s.end_time,
|
||||
"activity_type": s.exercise_type_name,
|
||||
"activity_type_id": s.exercise_type,
|
||||
"duration_seconds": s.duration_seconds,
|
||||
"distance_meters": s.distance_meters or 0.0,
|
||||
"calories": s.calories_burned or 0.0,
|
||||
"avg_hr": s.avg_heart_rate,
|
||||
"max_hr": s.max_heart_rate,
|
||||
"min_hr": s.min_heart_rate
|
||||
}
|
||||
for s in sessions
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def fetch_sleep_sessions(
|
||||
self,
|
||||
access_token: Optional[str],
|
||||
days: int = 30
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch sleep sessions from Health Connect data store.
|
||||
|
||||
Note: Health Connect stores sleep as part of daily metrics.
|
||||
This returns an empty list as sleep is handled in fetch_daily_metrics.
|
||||
"""
|
||||
print(f"[HealthConnect] fetch_sleep_sessions called")
|
||||
# Sleep data is included in daily metrics for Health Connect
|
||||
return []
|
||||
|
||||
|
||||
# Register the provider with the factory
|
||||
HealthProviderFactory.register("health_connect", HealthConnectProvider)
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Health Data Provider - Abstract interface for health data sources.
|
||||
|
||||
This module provides an abstraction layer that allows switching between
|
||||
different health data backends (Google Fit, Health Connect, etc.) without
|
||||
changing the API layer.
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class DataSource(str, Enum):
|
||||
"""Enum for tracking data provenance."""
|
||||
HEALTH_CONNECT = "health_connect"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class HealthDataProvider(ABC):
|
||||
"""
|
||||
Abstract base class for health data providers.
|
||||
|
||||
Implementations must provide methods to fetch daily metrics,
|
||||
workout sessions, and sleep data from their respective sources.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def source(self) -> DataSource:
|
||||
"""Return the data source identifier."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def fetch_daily_metrics(
|
||||
self,
|
||||
access_token: Optional[str],
|
||||
days: int = 7,
|
||||
timezone: str = "UTC",
|
||||
user_id: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch aggregated daily health metrics.
|
||||
|
||||
Args:
|
||||
access_token: OAuth token or API key
|
||||
days: Number of days to fetch
|
||||
timezone: User's timezone for day boundaries
|
||||
user_id: Optional user ID for logging
|
||||
|
||||
Returns:
|
||||
List of daily metric dictionaries with keys:
|
||||
- date: date object
|
||||
- step_count: int
|
||||
- calories_burned: float
|
||||
- distance_meters: float
|
||||
- sleep_duration_minutes: int
|
||||
- deep_sleep_minutes: int
|
||||
- light_sleep_minutes: int
|
||||
- rem_sleep_minutes: int
|
||||
- awake_duration_minutes: int
|
||||
- avg_heart_rate: int
|
||||
- min_heart_rate: int
|
||||
- max_heart_rate: int
|
||||
- avg_spo2: float
|
||||
- pai_score: float
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def fetch_workouts(
|
||||
self,
|
||||
access_token: Optional[str],
|
||||
days: int = 30
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch workout sessions.
|
||||
|
||||
Args:
|
||||
access_token: OAuth token or API key
|
||||
days: Number of days to fetch
|
||||
|
||||
Returns:
|
||||
List of workout dictionaries with keys:
|
||||
- start_time: datetime
|
||||
- end_time: datetime
|
||||
- activity_type: str (human readable)
|
||||
- activity_type_id: int (source-specific ID)
|
||||
- duration_seconds: int
|
||||
- distance_meters: float
|
||||
- calories: float
|
||||
- avg_hr: int
|
||||
- max_hr: int
|
||||
- min_hr: int
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def fetch_sleep_sessions(
|
||||
self,
|
||||
access_token: Optional[str],
|
||||
days: int = 30
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch sleep sessions for total duration validation.
|
||||
|
||||
Args:
|
||||
access_token: OAuth token or API key
|
||||
days: Number of days to fetch
|
||||
|
||||
Returns:
|
||||
List of sleep session dictionaries with keys:
|
||||
- date: date object (wake-up date)
|
||||
- duration_minutes: int
|
||||
- start_time: datetime
|
||||
- end_time: datetime
|
||||
- type: str
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_auth_url(self) -> str:
|
||||
"""
|
||||
Get the OAuth authorization URL for this provider.
|
||||
|
||||
Returns:
|
||||
Authorization URL string
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class HealthProviderFactory:
|
||||
"""
|
||||
Factory for creating health data provider instances.
|
||||
|
||||
Uses the HEALTH_PROVIDER environment variable to determine
|
||||
which provider to instantiate.
|
||||
"""
|
||||
|
||||
_providers: Dict[str, type] = {}
|
||||
|
||||
@classmethod
|
||||
def register(cls, name: str, provider_class: type):
|
||||
"""Register a provider class with a name."""
|
||||
cls._providers[name] = provider_class
|
||||
|
||||
@classmethod
|
||||
def get_provider(cls, name: Optional[str] = None) -> HealthDataProvider:
|
||||
"""
|
||||
Get a provider instance by name.
|
||||
|
||||
Args:
|
||||
name: Provider name. If None, uses HEALTH_PROVIDER env var.
|
||||
|
||||
Returns:
|
||||
HealthDataProvider instance
|
||||
|
||||
Raises:
|
||||
ValueError: If provider name is not registered
|
||||
"""
|
||||
from backend.core.config import settings
|
||||
|
||||
provider_name = name or getattr(settings, 'HEALTH_PROVIDER', 'health_connect')
|
||||
|
||||
if provider_name not in cls._providers:
|
||||
available = list(cls._providers.keys())
|
||||
raise ValueError(
|
||||
f"Unknown provider '{provider_name}'. Available: {available}"
|
||||
)
|
||||
|
||||
return cls._providers[provider_name]()
|
||||
|
||||
@classmethod
|
||||
def list_providers(cls) -> List[str]:
|
||||
"""List all registered provider names."""
|
||||
return list(cls._providers.keys())
|
||||
@@ -0,0 +1,434 @@
|
||||
"""
|
||||
Zepp Data Import Module
|
||||
|
||||
Imports health data from Zepp/Mi Band/Amazfit export files into HabitForge database.
|
||||
Supports: Activity, Sleep, Heart Rate, Body measurements, and Sport/Workouts.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import glob
|
||||
import os
|
||||
import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import create_engine
|
||||
from backend.models import DailyHealthMetrics, User, WorkoutSession
|
||||
from backend.models.workout_session import ZEPP_ACTIVITY_TYPES
|
||||
from backend.database import SessionLocal, engine, Base
|
||||
|
||||
# --- Configuration ---
|
||||
EXPORT_DIR = "export-zepp"
|
||||
USER_ID = 1 # Default user ID since we are single user for now or running locally
|
||||
|
||||
|
||||
def parse_date(date_str):
|
||||
"""Parse YYYY-MM-DD date string."""
|
||||
if not date_str:
|
||||
return None
|
||||
try:
|
||||
return datetime.datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_datetime(dt_str):
|
||||
"""Parse datetime string with timezone (e.g., 2025-12-28 18:25:54+0000)."""
|
||||
if not dt_str:
|
||||
return None
|
||||
try:
|
||||
# Remove timezone for simplicity
|
||||
clean_str = dt_str.split('+')[0].split('-0')[0]
|
||||
return datetime.datetime.strptime(clean_str, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def get_latest_export_dir(base_dir):
|
||||
"""Find the numbered directory inside export-zepp."""
|
||||
subdirs = glob.glob(os.path.join(base_dir, "*"))
|
||||
if not subdirs:
|
||||
return None
|
||||
# Return the most recent one (sorted by name, which includes timestamp)
|
||||
return sorted(subdirs)[-1]
|
||||
|
||||
|
||||
def import_activity(session, export_path):
|
||||
"""Import daily activity summary (steps, distance, calories)."""
|
||||
activity_dir = os.path.join(export_path, "ACTIVITY")
|
||||
csv_files = glob.glob(os.path.join(activity_dir, "*.csv"))
|
||||
if not csv_files:
|
||||
print("No Activity CSV found.")
|
||||
return
|
||||
|
||||
count = 0
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Activity: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
d_date = parse_date(row.get('date'))
|
||||
if not d_date:
|
||||
continue
|
||||
|
||||
steps = int(row.get('steps', 0))
|
||||
distance = float(row.get('distance', 0))
|
||||
calories = float(row.get('calories', 0))
|
||||
|
||||
# Upsert
|
||||
metric = session.query(DailyHealthMetrics).filter_by(
|
||||
user_id=USER_ID, date=d_date
|
||||
).first()
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
|
||||
session.add(metric)
|
||||
|
||||
# Overwrite with Zepp data as it's authoritative
|
||||
if steps > 0:
|
||||
metric.step_count = steps
|
||||
if distance > 0:
|
||||
metric.distance_meters = distance
|
||||
if calories > 0:
|
||||
metric.calories_burned = calories
|
||||
count += 1
|
||||
|
||||
session.commit()
|
||||
print(f" Imported {count} activity records.")
|
||||
|
||||
|
||||
def import_sleep(session, export_path):
|
||||
"""Import sleep data with phases (deep, light, REM, awake)."""
|
||||
sleep_dir = os.path.join(export_path, "SLEEP")
|
||||
csv_files = glob.glob(os.path.join(sleep_dir, "*.csv"))
|
||||
|
||||
count = 0
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Sleep: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
d_date = parse_date(row.get('date'))
|
||||
if not d_date:
|
||||
continue
|
||||
|
||||
try:
|
||||
deep = int(row.get('deepSleepTime', 0))
|
||||
light = int(row.get('shallowSleepTime', 0))
|
||||
wake = int(row.get('wakeTime', 0))
|
||||
rem = int(row.get('REMTime', 0))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
total_sleep = deep + light + rem
|
||||
|
||||
metric = session.query(DailyHealthMetrics).filter_by(
|
||||
user_id=USER_ID, date=d_date
|
||||
).first()
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
|
||||
session.add(metric)
|
||||
|
||||
if total_sleep > 0:
|
||||
metric.sleep_duration_minutes = total_sleep
|
||||
metric.deep_sleep_minutes = deep
|
||||
metric.light_sleep_minutes = light
|
||||
metric.rem_sleep_minutes = rem
|
||||
metric.awake_duration_minutes = wake
|
||||
count += 1
|
||||
|
||||
session.commit()
|
||||
print(f" Imported {count} sleep records.")
|
||||
|
||||
|
||||
def import_heart_rate(session, export_path):
|
||||
"""Import manual heart rate readings."""
|
||||
hr_dir = os.path.join(export_path, "HEARTRATE")
|
||||
csv_files = glob.glob(os.path.join(hr_dir, "*.csv"))
|
||||
|
||||
daily_hr = {} # date -> [hr_values]
|
||||
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Heart Rate: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
ts_str = row.get('time')
|
||||
hr_val = int(row.get('heartRate', 0))
|
||||
|
||||
if not ts_str or hr_val <= 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
dt = datetime.datetime.strptime(ts_str.split('+')[0], "%Y-%m-%d %H:%M:%S")
|
||||
d_date = dt.date()
|
||||
|
||||
if d_date not in daily_hr:
|
||||
daily_hr[d_date] = []
|
||||
daily_hr[d_date].append(hr_val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Aggregate
|
||||
for d_date, values in daily_hr.items():
|
||||
if not values:
|
||||
continue
|
||||
avg_hr = sum(values) // len(values)
|
||||
min_hr = min(values)
|
||||
max_hr = max(values)
|
||||
|
||||
metric = session.query(DailyHealthMetrics).filter_by(
|
||||
user_id=USER_ID, date=d_date
|
||||
).first()
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
|
||||
session.add(metric)
|
||||
|
||||
metric.avg_heart_rate = avg_hr
|
||||
metric.min_heart_rate = min_hr
|
||||
metric.max_heart_rate = max_hr
|
||||
|
||||
session.commit()
|
||||
print(f" Imported heart rate data for {len(daily_hr)} days.")
|
||||
|
||||
|
||||
def import_heart_rate_auto(session, export_path):
|
||||
"""Import automatic heart rate monitoring data."""
|
||||
hr_dir = os.path.join(export_path, "HEARTRATE_AUTO")
|
||||
csv_files = glob.glob(os.path.join(hr_dir, "*.csv"))
|
||||
|
||||
if not csv_files:
|
||||
print("No HEARTRATE_AUTO CSV found.")
|
||||
return
|
||||
|
||||
daily_hr = {}
|
||||
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Heart Rate Auto: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
date_str = row.get('date')
|
||||
hr_val = int(row.get('heartRate', 0))
|
||||
|
||||
if not date_str or hr_val <= 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
d_date = parse_date(date_str)
|
||||
if not d_date:
|
||||
continue
|
||||
|
||||
if d_date not in daily_hr:
|
||||
daily_hr[d_date] = []
|
||||
daily_hr[d_date].append(hr_val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Aggregate and Save
|
||||
for d_date, values in daily_hr.items():
|
||||
if not values:
|
||||
continue
|
||||
|
||||
avg_hr = sum(values) // len(values)
|
||||
min_hr = min(values)
|
||||
max_hr = max(values)
|
||||
rhr = min_hr # Resting HR approximation
|
||||
|
||||
metric = session.query(DailyHealthMetrics).filter_by(
|
||||
user_id=USER_ID, date=d_date
|
||||
).first()
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
|
||||
session.add(metric)
|
||||
|
||||
metric.avg_heart_rate = avg_hr
|
||||
metric.min_heart_rate = min_hr
|
||||
metric.max_heart_rate = max_hr
|
||||
metric.resting_heart_rate = rhr
|
||||
|
||||
session.commit()
|
||||
print(f" Imported auto heart rate data for {len(daily_hr)} days.")
|
||||
|
||||
|
||||
def import_body(session, export_path):
|
||||
"""Import body measurements (weight, etc.)."""
|
||||
body_dir = os.path.join(export_path, "BODY")
|
||||
csv_files = glob.glob(os.path.join(body_dir, "*.csv"))
|
||||
|
||||
count = 0
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Body: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
ts_str = row.get('time')
|
||||
w_str = row.get('weight', '0')
|
||||
weight = float(w_str)
|
||||
|
||||
if not ts_str or weight <= 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
dt = datetime.datetime.strptime(ts_str.split('+')[0], "%Y-%m-%d %H:%M:%S")
|
||||
d_date = dt.date()
|
||||
|
||||
metric = session.query(DailyHealthMetrics).filter_by(
|
||||
user_id=USER_ID, date=d_date
|
||||
).first()
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
|
||||
session.add(metric)
|
||||
|
||||
metric.weight = weight
|
||||
count += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
session.commit()
|
||||
print(f" Imported {count} body measurements.")
|
||||
|
||||
|
||||
def import_sport(session, export_path):
|
||||
"""Import sport/workout sessions."""
|
||||
sport_dir = os.path.join(export_path, "SPORT")
|
||||
csv_files = glob.glob(os.path.join(sport_dir, "*.csv"))
|
||||
|
||||
if not csv_files:
|
||||
print("No SPORT CSV found.")
|
||||
return
|
||||
|
||||
count = 0
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Sport: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f)
|
||||
# Columns: type, startTime, sportTime(s), maxPace(/meter), minPace(/meter),
|
||||
# distance(m), avgPace(/meter), calories(kcal)
|
||||
for row in reader:
|
||||
try:
|
||||
type_id = int(row.get('type', 0))
|
||||
start_time_str = row.get('startTime')
|
||||
duration_seconds = int(row.get('sportTime(s)', 0))
|
||||
distance = float(row.get('distance(m)', 0))
|
||||
calories = float(row.get('calories(kcal)', 0))
|
||||
max_pace = float(row.get('maxPace(/meter)', 0))
|
||||
min_pace = float(row.get('minPace(/meter)', 0))
|
||||
avg_pace = float(row.get('avgPace(/meter)', 0))
|
||||
|
||||
start_time = parse_datetime(start_time_str)
|
||||
if not start_time:
|
||||
continue
|
||||
|
||||
end_time = start_time + datetime.timedelta(seconds=duration_seconds)
|
||||
activity_name = WorkoutSession.get_activity_name(type_id)
|
||||
|
||||
# Check if workout already exists (by start time and user)
|
||||
existing = session.query(WorkoutSession).filter_by(
|
||||
user_id=USER_ID, start_time=start_time
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# Update existing
|
||||
existing.activity_type = activity_name
|
||||
existing.activity_type_id = type_id
|
||||
existing.duration_seconds = duration_seconds
|
||||
existing.distance_meters = distance
|
||||
existing.calories = calories
|
||||
existing.avg_pace = avg_pace if avg_pace > 0 else None
|
||||
existing.max_pace = max_pace if max_pace > 0 else None
|
||||
existing.min_pace = min_pace if min_pace > 0 else None
|
||||
else:
|
||||
# Create new
|
||||
workout = WorkoutSession(
|
||||
user_id=USER_ID,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
activity_type=activity_name,
|
||||
activity_type_id=type_id,
|
||||
duration_seconds=duration_seconds,
|
||||
distance_meters=distance,
|
||||
calories=calories,
|
||||
avg_pace=avg_pace if avg_pace > 0 else None,
|
||||
max_pace=max_pace if max_pace > 0 else None,
|
||||
min_pace=min_pace if min_pace > 0 else None
|
||||
)
|
||||
session.add(workout)
|
||||
count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error processing workout: {e}")
|
||||
continue
|
||||
|
||||
session.commit()
|
||||
print(f" Imported {count} workout sessions.")
|
||||
|
||||
|
||||
def calculate_pai_scores(session):
|
||||
"""Calculate PAI scores for all days based on activity and heart rate data."""
|
||||
print("Calculating PAI scores...")
|
||||
|
||||
metrics = session.query(DailyHealthMetrics).filter_by(user_id=USER_ID).all()
|
||||
|
||||
for metric in metrics:
|
||||
# Simple PAI calculation based on activity
|
||||
# Real PAI requires continuous HR data, this is an approximation
|
||||
pai = 0.0
|
||||
|
||||
# Base PAI from steps (rough approximation)
|
||||
if metric.step_count:
|
||||
pai += metric.step_count / 1000 # 1 PAI per 1000 steps
|
||||
|
||||
# Bonus from calories burned (beyond baseline)
|
||||
if metric.calories_burned and metric.calories_burned > 0:
|
||||
pai += metric.calories_burned / 50 # 1 PAI per 50 cal
|
||||
|
||||
# Bonus from elevated heart rate activities
|
||||
if metric.avg_heart_rate and metric.avg_heart_rate > 100:
|
||||
intensity_bonus = (metric.avg_heart_rate - 100) / 10
|
||||
pai += intensity_bonus
|
||||
|
||||
metric.pai_score = round(min(pai, 100), 1) # Cap at 100
|
||||
|
||||
session.commit()
|
||||
print(f" Updated PAI scores for {len(metrics)} days.")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main import function."""
|
||||
print("=" * 60)
|
||||
print("Starting Zepp Data Import...")
|
||||
print("=" * 60)
|
||||
|
||||
session = SessionLocal()
|
||||
|
||||
try:
|
||||
base_export = get_latest_export_dir(EXPORT_DIR)
|
||||
if not base_export:
|
||||
print(f"No export directory found in {EXPORT_DIR}")
|
||||
return
|
||||
|
||||
print(f"Using export directory: {base_export}\n")
|
||||
|
||||
# Import all data types
|
||||
import_activity(session, base_export)
|
||||
import_sleep(session, base_export)
|
||||
import_heart_rate(session, base_export)
|
||||
import_heart_rate_auto(session, base_export)
|
||||
import_body(session, base_export)
|
||||
import_sport(session, base_export)
|
||||
|
||||
# Calculate derived metrics
|
||||
calculate_pai_scores(session)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Import completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nAn error occurred: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,75 @@
|
||||
from fastapi import FastAPI, Depends, HTTPException, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy.orm import Session
|
||||
import os
|
||||
|
||||
from backend.core.config import settings
|
||||
from backend.api.v1 import (
|
||||
auth as auth_router,
|
||||
challenges as challenges_router,
|
||||
health as health_router,
|
||||
# google as google_router,
|
||||
health_connect as health_connect_router,
|
||||
health_connect_raw as health_connect_raw_router,
|
||||
health_analytics as health_analytics_router
|
||||
)
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.util import get_remote_address
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
|
||||
# Rate limiting setup
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
app = FastAPI(title=settings.APP_NAME, version=settings.APP_VERSION)
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
# Create database tables on startup
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
from backend.database import Base, engine
|
||||
import backend.models # This registers all models in the namespace for Base.metadata.create_all
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# CORS - Use configured origins
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=settings.CORS_ALLOW_CREDENTIALS,
|
||||
allow_methods=settings.CORS_ALLOW_METHODS,
|
||||
allow_headers=settings.CORS_ALLOW_HEADERS,
|
||||
)
|
||||
|
||||
# Include API v1 routers
|
||||
app.include_router(auth_router.router)
|
||||
app.include_router(challenges_router.router)
|
||||
app.include_router(health_router.router)
|
||||
# app.include_router(google_router.router)
|
||||
app.include_router(health_connect_router.router)
|
||||
app.include_router(health_connect_raw_router.router)
|
||||
app.include_router(health_analytics_router.router)
|
||||
|
||||
# Middleware to drop unexpected WebSocket connections (Fixes StaticFiles crash)
|
||||
@app.middleware("http")
|
||||
async def reject_websockets(request, call_next):
|
||||
return await call_next(request)
|
||||
|
||||
from starlette.websockets import WebSocket
|
||||
@app.websocket("/{path:path}")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
await websocket.close()
|
||||
|
||||
@app.get("/api/static/images")
|
||||
def get_static_images():
|
||||
"""Retrieve list of available static images."""
|
||||
static_dir = os.path.join("frontend", "static")
|
||||
images = []
|
||||
if os.path.exists(static_dir):
|
||||
for filename in os.listdir(static_dir):
|
||||
if filename.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')):
|
||||
images.append(filename)
|
||||
return images
|
||||
|
||||
# Mount static files last to avoid intercepting API calls
|
||||
app.mount("/", StaticFiles(directory="frontend", html=True), name="frontend")
|
||||
@@ -0,0 +1,179 @@
|
||||
from sqlalchemy import Column, Integer, String, Date, Boolean, ForeignKey, Text, Float, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from .database import Base
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import date
|
||||
|
||||
# --- SQLAlchemy Models ---
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String, unique=True, index=True)
|
||||
hashed_password = Column(String)
|
||||
|
||||
challenges = relationship("Challenge", back_populates="owner")
|
||||
health_metrics = relationship("DailyHealthMetrics", back_populates="user")
|
||||
workouts = relationship("WorkoutSession", back_populates="user")
|
||||
|
||||
class Challenge(Base):
|
||||
__tablename__ = "challenges"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, index=True)
|
||||
period_type = Column(String) # 'week', 'month', 'year'
|
||||
daily_target = Column(Integer)
|
||||
start_date = Column(Date)
|
||||
end_date = Column(Date)
|
||||
created_at = Column(Date, default=date.today)
|
||||
display_order = Column(Integer, default=0)
|
||||
user_id = Column(Integer, ForeignKey("users.id")) # New FK
|
||||
unit_type = Column(String, default="reps") # New: 'reps', 'minutes', 'pages', 'km'
|
||||
rest_days = Column(String, nullable=True) # New: comma-separated list of days
|
||||
icon = Column(String, default="fa-dumbbell") # New
|
||||
image_url = Column(String, nullable=True) # New
|
||||
description = Column(String, nullable=True) # New
|
||||
frequency_count = Column(Integer, nullable=True) # New
|
||||
selected_days = Column(String, nullable=True) # New
|
||||
|
||||
owner = relationship("User", back_populates="challenges") # New Rel
|
||||
trackings = relationship("Tracking", back_populates="challenge", cascade="all, delete-orphan")
|
||||
|
||||
class Tracking(Base):
|
||||
__tablename__ = "tracking"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
challenge_id = Column(Integer, ForeignKey("challenges.id"))
|
||||
date = Column(Date)
|
||||
reps = Column(Integer, default=0)
|
||||
completed = Column(Boolean, default=False)
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
challenge = relationship("Challenge", back_populates="trackings")
|
||||
|
||||
class DailyHealthMetrics(Base):
|
||||
__tablename__ = "daily_health_metrics"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
date = Column(Date, index=True)
|
||||
step_count = Column(Integer, default=0)
|
||||
distance_meters = Column(Float, default=0.0)
|
||||
calories_burned = Column(Float, default=0.0)
|
||||
sleep_duration_minutes = Column(Integer, default=0)
|
||||
deep_sleep_minutes = Column(Integer, default=0)
|
||||
light_sleep_minutes = Column(Integer, default=0) # New
|
||||
rem_sleep_minutes = Column(Integer, default=0) # New
|
||||
awake_duration_minutes = Column(Integer, default=0)
|
||||
avg_heart_rate = Column(Integer, nullable=True)
|
||||
min_heart_rate = Column(Integer, nullable=True)
|
||||
max_heart_rate = Column(Integer, nullable=True)
|
||||
avg_spo2 = Column(Float, nullable=True)
|
||||
pai_score = Column(Float, default=0.0)
|
||||
weight = Column(Float, nullable=True) # New
|
||||
resting_heart_rate = Column(Integer, nullable=True) # New
|
||||
hrv = Column(Float, nullable=True) # New
|
||||
|
||||
user = relationship("User", back_populates="health_metrics")
|
||||
|
||||
class WorkoutSession(Base):
|
||||
__tablename__ = "workout_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
start_time = Column(DateTime)
|
||||
end_time = Column(DateTime)
|
||||
activity_type = Column(String)
|
||||
calories = Column(Float, default=0.0)
|
||||
avg_hr = Column(Integer, nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="workouts")
|
||||
|
||||
|
||||
# --- Pydantic Schemas ---
|
||||
|
||||
class TrackingBase(BaseModel):
|
||||
date: date
|
||||
reps: int
|
||||
completed: bool = False
|
||||
notes: Optional[str] = None
|
||||
|
||||
class TrackingCreate(TrackingBase):
|
||||
challenge_id: int
|
||||
|
||||
class TrackingResponse(TrackingBase):
|
||||
id: int
|
||||
challenge_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ChallengeBase(BaseModel):
|
||||
name: str
|
||||
period_type: str
|
||||
daily_target: int
|
||||
start_date: date
|
||||
end_date: Optional[date] = None
|
||||
unit_type: Optional[str] = "reps"
|
||||
rest_days: Optional[str] = None
|
||||
icon: Optional[str] = "fa-dumbbell"
|
||||
image_url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
frequency_count: Optional[int] = None # For Monthly frequency (X times per month)
|
||||
selected_days: Optional[str] = None # For Weekly frequency (Mon,Wed,Fri)
|
||||
|
||||
class ChallengeCreate(ChallengeBase):
|
||||
pass
|
||||
|
||||
class ChallengeResponse(ChallengeBase):
|
||||
id: int
|
||||
created_at: date
|
||||
display_order: int = 0
|
||||
trackings: List[TrackingResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class DailyHealthMetricsBase(BaseModel):
|
||||
date: date
|
||||
step_count: Optional[int] = 0
|
||||
distance_meters: Optional[float] = 0.0
|
||||
calories_burned: Optional[float] = 0.0
|
||||
sleep_duration_minutes: Optional[int] = 0
|
||||
deep_sleep_minutes: Optional[int] = 0
|
||||
light_sleep_minutes: Optional[int] = 0
|
||||
rem_sleep_minutes: Optional[int] = 0
|
||||
awake_duration_minutes: Optional[int] = 0
|
||||
avg_heart_rate: Optional[int] = None
|
||||
min_heart_rate: Optional[int] = None
|
||||
max_heart_rate: Optional[int] = None
|
||||
avg_spo2: Optional[float] = None
|
||||
pai_score: Optional[float] = 0.0
|
||||
weight: Optional[float] = None
|
||||
resting_heart_rate: Optional[int] = None
|
||||
hrv: Optional[float] = None
|
||||
|
||||
class DailyHealthMetricsResponse(DailyHealthMetricsBase):
|
||||
id: int
|
||||
user_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Auth Schemas
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
@@ -0,0 +1,59 @@
|
||||
"""SQLAlchemy models module."""
|
||||
from backend.models.user import User
|
||||
from backend.models.challenge import Challenge
|
||||
from backend.models.tracking import Tracking
|
||||
from backend.models.daily_health_metrics import DailyHealthMetrics
|
||||
from backend.models.workout_session import WorkoutSession
|
||||
from backend.models.daily_context import DailyContext
|
||||
from backend.models.health_connect import (
|
||||
HealthConnectSyncLog,
|
||||
HealthConnectDailyMetrics,
|
||||
HealthConnectExerciseSession,
|
||||
)
|
||||
from backend.models.health_connect_raw import (
|
||||
HCRawSteps,
|
||||
HCRawHeartRate,
|
||||
HCRawHeartRateSample,
|
||||
HCRawRestingHeartRate,
|
||||
HCRawSleepSession,
|
||||
HCRawSleepStage,
|
||||
HCRawDistance,
|
||||
HCRawCalories,
|
||||
HCRawOxygenSaturation,
|
||||
HCRawWeight,
|
||||
HCRawHeight,
|
||||
HCRawBodyFat,
|
||||
HCRawExerciseSession,
|
||||
HCRawNutrition,
|
||||
HCRawHydration,
|
||||
HCRawHeartRateVariabilityRmssd,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Challenge",
|
||||
"Tracking",
|
||||
"DailyHealthMetrics",
|
||||
"WorkoutSession",
|
||||
"DailyContext",
|
||||
"HealthConnectSyncLog",
|
||||
"HealthConnectDailyMetrics",
|
||||
"HealthConnectExerciseSession",
|
||||
# Raw data models
|
||||
"HCRawSteps",
|
||||
"HCRawHeartRate",
|
||||
"HCRawHeartRateSample",
|
||||
"HCRawRestingHeartRate",
|
||||
"HCRawSleepSession",
|
||||
"HCRawSleepStage",
|
||||
"HCRawDistance",
|
||||
"HCRawCalories",
|
||||
"HCRawOxygenSaturation",
|
||||
"HCRawWeight",
|
||||
"HCRawHeight",
|
||||
"HCRawBodyFat",
|
||||
"HCRawExerciseSession",
|
||||
"HCRawNutrition",
|
||||
"HCRawHydration",
|
||||
"HCRawHeartRateVariabilityRmssd",
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Challenge SQLAlchemy model."""
|
||||
from sqlalchemy import Column, Integer, String, Date, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import date
|
||||
from backend.database import Base
|
||||
|
||||
|
||||
class Challenge(Base):
|
||||
"""Challenge model for tracking habits."""
|
||||
__tablename__ = "challenges"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, index=True)
|
||||
period_type = Column(String) # 'week', 'month', 'year'
|
||||
daily_target = Column(Integer)
|
||||
start_date = Column(Date)
|
||||
end_date = Column(Date)
|
||||
created_at = Column(Date, default=date.today)
|
||||
display_order = Column(Integer, default=0)
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
unit_type = Column(String, default="reps") # 'reps', 'minutes', 'pages', 'km'
|
||||
rest_days = Column(String, nullable=True) # comma-separated list of days
|
||||
icon = Column(String, default="fa-dumbbell")
|
||||
image_url = Column(String, nullable=True)
|
||||
description = Column(String, nullable=True)
|
||||
frequency_count = Column(Integer, nullable=True)
|
||||
selected_days = Column(String, nullable=True)
|
||||
|
||||
owner = relationship("User", back_populates="challenges")
|
||||
trackings = relationship("Tracking", back_populates="challenge", cascade="all, delete-orphan")
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Daily Context Model - Stores subjective user inputs like mood, tags, etc."""
|
||||
from sqlalchemy import Column, Integer, String, Date, ForeignKey, UniqueConstraint
|
||||
from backend.database import Base
|
||||
|
||||
class DailyContext(Base):
|
||||
__tablename__ = "daily_context"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
date = Column(Date, index=True)
|
||||
|
||||
mood_score = Column(Integer) # 1-100 slider
|
||||
tags = Column(String, nullable=True) # Comma separated tags e.g. "Alcohol,Stress"
|
||||
notes = Column(String, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'date', name='uq_daily_context'),
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""DailyHealthMetrics SQLAlchemy model."""
|
||||
from sqlalchemy import Column, Integer, Date, Float, ForeignKey, UniqueConstraint, String
|
||||
from sqlalchemy.orm import relationship
|
||||
from backend.database import Base
|
||||
|
||||
|
||||
class DailyHealthMetrics(Base):
|
||||
"""Daily health metrics model for tracking health data."""
|
||||
__tablename__ = "daily_health_metrics"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'date', name='uq_user_date'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
date = Column(Date, index=True)
|
||||
step_count = Column(Integer, default=0)
|
||||
distance_meters = Column(Float, default=0.0)
|
||||
calories_burned = Column(Float, default=0.0)
|
||||
sleep_duration_minutes = Column(Integer, default=0)
|
||||
deep_sleep_minutes = Column(Integer, default=0)
|
||||
light_sleep_minutes = Column(Integer, default=0)
|
||||
rem_sleep_minutes = Column(Integer, default=0)
|
||||
awake_duration_minutes = Column(Integer, default=0)
|
||||
avg_heart_rate = Column(Integer, nullable=True)
|
||||
min_heart_rate = Column(Integer, nullable=True)
|
||||
max_heart_rate = Column(Integer, nullable=True)
|
||||
avg_spo2 = Column(Float, nullable=True)
|
||||
pai_score = Column(Float, default=0.0)
|
||||
weight = Column(Float, nullable=True)
|
||||
resting_heart_rate = Column(Integer, nullable=True)
|
||||
hrv = Column(Float, nullable=True)
|
||||
|
||||
# Data provenance tracking
|
||||
data_source = Column(String(50), default="health_connect") # "health_connect", "manual"
|
||||
|
||||
user = relationship("User", back_populates="health_metrics")
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Health Connect Data Models - Storage for data pushed from Android companion app.
|
||||
|
||||
These models store health data received from the Health Connect Android app,
|
||||
enabling the HealthConnectProvider to serve data to the API layer.
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Float, Date, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from backend.database import Base
|
||||
import datetime
|
||||
|
||||
|
||||
class HealthConnectSyncLog(Base):
|
||||
"""
|
||||
Tracks sync operations from the Android companion app.
|
||||
Used for debugging and monitoring data flow.
|
||||
"""
|
||||
__tablename__ = "health_connect_sync_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
device_id = Column(String(100)) # Android device identifier
|
||||
sync_timestamp = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
records_pushed = Column(Integer, default=0)
|
||||
status = Column(String(50)) # "success", "partial", "error"
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class HealthConnectDailyMetrics(Base):
|
||||
"""
|
||||
Daily health metrics received from Health Connect.
|
||||
Mirrors DailyHealthMetrics but with Health Connect-specific metadata.
|
||||
"""
|
||||
__tablename__ = "health_connect_daily_metrics"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
date = Column(Date, index=True)
|
||||
|
||||
# Data source identification
|
||||
data_source = Column(String(200), nullable=True) # Package name of originating app (e.g., com.huami.watch.hmwatchmanager)
|
||||
data_source_name = Column(String(100), nullable=True) # Friendly name (e.g., "Zepp")
|
||||
|
||||
# Activity metrics
|
||||
step_count = Column(Integer, default=0)
|
||||
distance_meters = Column(Float, default=0.0)
|
||||
calories_burned = Column(Float, default=0.0) # ActiveCaloriesBurnedRecord
|
||||
total_calories = Column(Float, default=0.0) # TotalCaloriesBurnedRecord
|
||||
|
||||
# Sleep metrics (from SleepSessionRecord)
|
||||
sleep_duration_minutes = Column(Integer, default=0)
|
||||
deep_sleep_minutes = Column(Integer, default=0)
|
||||
light_sleep_minutes = Column(Integer, default=0)
|
||||
rem_sleep_minutes = Column(Integer, default=0)
|
||||
awake_duration_minutes = Column(Integer, default=0)
|
||||
|
||||
# Heart rate metrics (from HeartRateRecord)
|
||||
avg_heart_rate = Column(Integer, nullable=True)
|
||||
min_heart_rate = Column(Integer, nullable=True)
|
||||
max_heart_rate = Column(Integer, nullable=True)
|
||||
resting_heart_rate = Column(Integer, nullable=True)
|
||||
|
||||
# SpO2 (from OxygenSaturationRecord)
|
||||
avg_spo2 = Column(Float, nullable=True)
|
||||
min_spo2 = Column(Float, nullable=True)
|
||||
max_spo2 = Column(Float, nullable=True)
|
||||
|
||||
# Body metrics
|
||||
weight = Column(Float, nullable=True) # WeightRecord
|
||||
|
||||
# Metadata
|
||||
last_synced = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class HealthConnectExerciseSession(Base):
|
||||
"""
|
||||
Exercise sessions from Health Connect ExerciseSessionRecord.
|
||||
"""
|
||||
__tablename__ = "health_connect_exercise_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
|
||||
# Session timing
|
||||
start_time = Column(DateTime, index=True)
|
||||
end_time = Column(DateTime)
|
||||
|
||||
# Exercise type (Health Connect ExerciseType enum)
|
||||
exercise_type = Column(Integer) # Health Connect type ID
|
||||
exercise_type_name = Column(String(100)) # Human-readable name
|
||||
|
||||
# Metrics
|
||||
duration_seconds = Column(Integer, default=0)
|
||||
distance_meters = Column(Float, nullable=True)
|
||||
calories_burned = Column(Float, nullable=True)
|
||||
|
||||
# Heart rate during exercise
|
||||
avg_heart_rate = Column(Integer, nullable=True)
|
||||
max_heart_rate = Column(Integer, nullable=True)
|
||||
min_heart_rate = Column(Integer, nullable=True)
|
||||
|
||||
# Additional metrics
|
||||
steps = Column(Integer, nullable=True)
|
||||
elevation_gained = Column(Float, nullable=True)
|
||||
|
||||
# Metadata
|
||||
notes = Column(Text, nullable=True)
|
||||
last_synced = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
# Health Connect Exercise Type mapping (subset)
|
||||
HEALTH_CONNECT_EXERCISE_TYPES = {
|
||||
0: "Unknown",
|
||||
2: "Badminton",
|
||||
4: "Baseball",
|
||||
5: "Basketball",
|
||||
8: "Biking",
|
||||
9: "Biking Stationary",
|
||||
10: "Boot Camp",
|
||||
11: "Boxing",
|
||||
13: "Calisthenics",
|
||||
14: "Cricket",
|
||||
16: "Dancing",
|
||||
25: "Elliptical",
|
||||
26: "Exercise Class",
|
||||
27: "Fencing",
|
||||
28: "Football American",
|
||||
29: "Football Australian",
|
||||
31: "Frisbee Disc",
|
||||
32: "Golf",
|
||||
33: "Guided Breathing",
|
||||
34: "Gymnastics",
|
||||
35: "Handball",
|
||||
36: "High Intensity Interval Training",
|
||||
37: "Hiking",
|
||||
38: "Ice Hockey",
|
||||
39: "Ice Skating",
|
||||
44: "Martial Arts",
|
||||
46: "Paddling",
|
||||
48: "Pilates",
|
||||
50: "Racquetball",
|
||||
51: "Rock Climbing",
|
||||
52: "Roller Hockey",
|
||||
53: "Rowing",
|
||||
54: "Rowing Machine",
|
||||
55: "Rugby",
|
||||
56: "Running",
|
||||
57: "Running Treadmill",
|
||||
58: "Sailing",
|
||||
59: "Scuba Diving",
|
||||
60: "Skating",
|
||||
61: "Skiing",
|
||||
62: "Snowboarding",
|
||||
63: "Snowshoeing",
|
||||
64: "Soccer",
|
||||
65: "Softball",
|
||||
67: "Squash",
|
||||
68: "Stair Climbing",
|
||||
69: "Stair Climbing Machine",
|
||||
70: "Strength Training",
|
||||
71: "Stretching",
|
||||
72: "Surfing",
|
||||
73: "Swimming Open Water",
|
||||
74: "Swimming Pool",
|
||||
75: "Table Tennis",
|
||||
76: "Tennis",
|
||||
78: "Volleyball",
|
||||
79: "Walking",
|
||||
80: "Water Polo",
|
||||
81: "Weightlifting",
|
||||
82: "Wheelchair",
|
||||
84: "Yoga",
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
"""
|
||||
Health Connect Raw Data Models - Storage for raw health records from Health Connect.
|
||||
|
||||
These models store individual health records (not aggregated) received from
|
||||
the Android companion app, enabling accurate aggregation on-demand.
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
from backend.database import Base
|
||||
import datetime
|
||||
|
||||
|
||||
# ==================== STEPS ====================
|
||||
|
||||
class HCRawSteps(Base):
|
||||
"""Individual step records from Health Connect StepsRecord"""
|
||||
__tablename__ = "hc_raw_steps"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
|
||||
# Record identification (from Health Connect metadata)
|
||||
record_id = Column(String(100), index=True) # metadata.id - unique per source
|
||||
data_source = Column(String(200)) # Package name (e.g., com.huami.watch.hmwatchmanager)
|
||||
data_source_name = Column(String(100)) # Friendly name (e.g., "Zepp")
|
||||
|
||||
# Timestamps
|
||||
start_time = Column(DateTime, index=True)
|
||||
end_time = Column(DateTime)
|
||||
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
# Data
|
||||
count = Column(Integer)
|
||||
|
||||
# Sync tracking
|
||||
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
|
||||
|
||||
# Unique constraint: same user + record_id + source = unique record
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_steps_record'),
|
||||
Index('ix_steps_user_date', 'user_id', 'start_time'),
|
||||
)
|
||||
|
||||
|
||||
# ==================== HEART RATE ====================
|
||||
|
||||
class HCRawHeartRate(Base):
|
||||
"""Heart rate record containers from Health Connect HeartRateRecord"""
|
||||
__tablename__ = "hc_raw_heart_rate"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
|
||||
record_id = Column(String(100), index=True)
|
||||
data_source = Column(String(200))
|
||||
data_source_name = Column(String(100))
|
||||
|
||||
start_time = Column(DateTime, index=True)
|
||||
end_time = Column(DateTime)
|
||||
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
|
||||
|
||||
# Relationship to samples
|
||||
samples = relationship("HCRawHeartRateSample", back_populates="record", cascade="all, delete-orphan")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_hr_record'),
|
||||
Index('ix_hr_user_date', 'user_id', 'start_time'),
|
||||
)
|
||||
|
||||
|
||||
class HCRawHeartRateSample(Base):
|
||||
"""Individual heart rate samples within a HeartRateRecord"""
|
||||
__tablename__ = "hc_raw_heart_rate_samples"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
record_id = Column(Integer, ForeignKey("hc_raw_heart_rate.id", ondelete="CASCADE"), index=True)
|
||||
|
||||
time = Column(DateTime, index=True)
|
||||
bpm = Column(Integer)
|
||||
|
||||
record = relationship("HCRawHeartRate", back_populates="samples")
|
||||
|
||||
|
||||
# ==================== RESTING HEART RATE ====================
|
||||
|
||||
class HCRawRestingHeartRate(Base):
|
||||
"""Resting heart rate records from Health Connect RestingHeartRateRecord"""
|
||||
__tablename__ = "hc_raw_resting_hr"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
|
||||
record_id = Column(String(100), index=True)
|
||||
data_source = Column(String(200))
|
||||
data_source_name = Column(String(100))
|
||||
|
||||
time = Column(DateTime, index=True)
|
||||
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
bpm = Column(Integer)
|
||||
|
||||
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_resting_hr_record'),
|
||||
Index('ix_resting_hr_user_date', 'user_id', 'time'),
|
||||
)
|
||||
|
||||
|
||||
# ==================== SLEEP ====================
|
||||
|
||||
class HCRawSleepSession(Base):
|
||||
"""Sleep session records from Health Connect SleepSessionRecord"""
|
||||
__tablename__ = "hc_raw_sleep_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
|
||||
record_id = Column(String(100), index=True)
|
||||
data_source = Column(String(200))
|
||||
data_source_name = Column(String(100))
|
||||
|
||||
start_time = Column(DateTime, index=True)
|
||||
end_time = Column(DateTime)
|
||||
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
title = Column(String(200), nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
|
||||
|
||||
# Relationship to stages
|
||||
stages = relationship("HCRawSleepStage", back_populates="session", cascade="all, delete-orphan")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_sleep_record'),
|
||||
Index('ix_sleep_user_date', 'user_id', 'start_time'),
|
||||
)
|
||||
|
||||
|
||||
class HCRawSleepStage(Base):
|
||||
"""Sleep stages within a sleep session"""
|
||||
__tablename__ = "hc_raw_sleep_stages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
session_id = Column(Integer, ForeignKey("hc_raw_sleep_sessions.id", ondelete="CASCADE"), index=True)
|
||||
|
||||
start_time = Column(DateTime)
|
||||
end_time = Column(DateTime)
|
||||
stage_type = Column(Integer) # Health Connect stage type enum
|
||||
stage_name = Column(String(50)) # Human readable: "awake", "light", "deep", "rem", etc.
|
||||
|
||||
session = relationship("HCRawSleepSession", back_populates="stages")
|
||||
|
||||
|
||||
# Sleep stage type constants (from Health Connect)
|
||||
SLEEP_STAGE_TYPES = {
|
||||
0: "unknown",
|
||||
1: "awake",
|
||||
2: "sleeping",
|
||||
3: "out_of_bed",
|
||||
4: "light",
|
||||
5: "deep",
|
||||
6: "rem",
|
||||
7: "awake_in_bed",
|
||||
}
|
||||
|
||||
|
||||
# ==================== DISTANCE ====================
|
||||
|
||||
class HCRawDistance(Base):
|
||||
"""Distance records from Health Connect DistanceRecord"""
|
||||
__tablename__ = "hc_raw_distance"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
|
||||
record_id = Column(String(100), index=True)
|
||||
data_source = Column(String(200))
|
||||
data_source_name = Column(String(100))
|
||||
|
||||
start_time = Column(DateTime, index=True)
|
||||
end_time = Column(DateTime)
|
||||
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
distance_meters = Column(Float)
|
||||
|
||||
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_distance_record'),
|
||||
Index('ix_distance_user_date', 'user_id', 'start_time'),
|
||||
)
|
||||
|
||||
|
||||
# ==================== CALORIES ====================
|
||||
|
||||
class HCRawCalories(Base):
|
||||
"""Calories records from Health Connect (ActiveCaloriesBurned or TotalCaloriesBurned)"""
|
||||
__tablename__ = "hc_raw_calories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
|
||||
record_id = Column(String(100), index=True)
|
||||
data_source = Column(String(200))
|
||||
data_source_name = Column(String(100))
|
||||
|
||||
start_time = Column(DateTime, index=True)
|
||||
end_time = Column(DateTime)
|
||||
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
calories = Column(Float)
|
||||
record_type = Column(String(20)) # "active" or "total"
|
||||
|
||||
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_calories_record'),
|
||||
Index('ix_calories_user_date', 'user_id', 'start_time'),
|
||||
)
|
||||
|
||||
|
||||
# ==================== OXYGEN SATURATION (SpO2) ====================
|
||||
|
||||
class HCRawOxygenSaturation(Base):
|
||||
"""Oxygen saturation records from Health Connect OxygenSaturationRecord"""
|
||||
__tablename__ = "hc_raw_spo2"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
|
||||
record_id = Column(String(100), index=True)
|
||||
data_source = Column(String(200))
|
||||
data_source_name = Column(String(100))
|
||||
|
||||
time = Column(DateTime, index=True)
|
||||
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
percentage = Column(Float)
|
||||
|
||||
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_spo2_record'),
|
||||
Index('ix_spo2_user_date', 'user_id', 'time'),
|
||||
)
|
||||
|
||||
|
||||
# ==================== WEIGHT ====================
|
||||
|
||||
class HCRawWeight(Base):
|
||||
"""Weight records from Health Connect WeightRecord"""
|
||||
__tablename__ = "hc_raw_weight"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
|
||||
record_id = Column(String(100), index=True)
|
||||
data_source = Column(String(200))
|
||||
data_source_name = Column(String(100))
|
||||
|
||||
time = Column(DateTime, index=True)
|
||||
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
weight_kg = Column(Float)
|
||||
|
||||
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_weight_record'),
|
||||
Index('ix_weight_user_date', 'user_id', 'time'),
|
||||
)
|
||||
|
||||
|
||||
# ==================== HEIGHT ====================
|
||||
|
||||
class HCRawHeight(Base):
|
||||
"""Height records from Health Connect HeightRecord"""
|
||||
__tablename__ = "hc_raw_height"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
|
||||
record_id = Column(String(100), index=True)
|
||||
data_source = Column(String(200))
|
||||
data_source_name = Column(String(100))
|
||||
|
||||
time = Column(DateTime, index=True)
|
||||
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
height_meters = Column(Float)
|
||||
|
||||
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_height_record'),
|
||||
Index('ix_height_user_date', 'user_id', 'time'),
|
||||
)
|
||||
|
||||
|
||||
# ==================== BODY FAT ====================
|
||||
|
||||
class HCRawBodyFat(Base):
|
||||
"""Body fat records from Health Connect BodyFatRecord"""
|
||||
__tablename__ = "hc_raw_body_fat"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
|
||||
record_id = Column(String(100), index=True)
|
||||
data_source = Column(String(200))
|
||||
data_source_name = Column(String(100))
|
||||
|
||||
time = Column(DateTime, index=True)
|
||||
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
percentage = Column(Float)
|
||||
|
||||
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_body_fat_record'),
|
||||
Index('ix_body_fat_user_date', 'user_id', 'time'),
|
||||
)
|
||||
|
||||
|
||||
# ==================== EXERCISE SESSIONS ====================
|
||||
|
||||
class HCRawExerciseSession(Base):
|
||||
"""Exercise session records from Health Connect ExerciseSessionRecord"""
|
||||
__tablename__ = "hc_raw_exercise_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
|
||||
record_id = Column(String(100), index=True)
|
||||
data_source = Column(String(200))
|
||||
data_source_name = Column(String(100))
|
||||
|
||||
start_time = Column(DateTime, index=True)
|
||||
end_time = Column(DateTime)
|
||||
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
# Exercise details
|
||||
exercise_type = Column(Integer) # Health Connect ExerciseType enum
|
||||
exercise_type_name = Column(String(100))
|
||||
title = Column(String(200), nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
# Associated metrics (if available in the session)
|
||||
distance_meters = Column(Float, nullable=True)
|
||||
calories = Column(Float, nullable=True)
|
||||
|
||||
# Heart rate during exercise
|
||||
avg_heart_rate = Column(Integer, nullable=True)
|
||||
max_heart_rate = Column(Integer, nullable=True)
|
||||
min_heart_rate = Column(Integer, nullable=True)
|
||||
|
||||
# Additional
|
||||
steps = Column(Integer, nullable=True)
|
||||
elevation_gained_meters = Column(Float, nullable=True)
|
||||
|
||||
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_exercise_record'),
|
||||
Index('ix_exercise_user_date', 'user_id', 'start_time'),
|
||||
)
|
||||
|
||||
|
||||
# ==================== NUTRITION ====================
|
||||
|
||||
class HCRawNutrition(Base):
|
||||
"""Nutrition records from Health Connect NutritionRecord"""
|
||||
__tablename__ = "hc_raw_nutrition"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
|
||||
record_id = Column(String(100), index=True)
|
||||
data_source = Column(String(200))
|
||||
data_source_name = Column(String(100))
|
||||
|
||||
start_time = Column(DateTime, index=True)
|
||||
end_time = Column(DateTime)
|
||||
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
# Basic nutrition info
|
||||
name = Column(String(200), nullable=True)
|
||||
meal_type = Column(Integer, nullable=True) # 0=unknown, 1=breakfast, 2=lunch, 3=dinner, 4=snack
|
||||
|
||||
# Macros
|
||||
calories = Column(Float, nullable=True)
|
||||
protein_grams = Column(Float, nullable=True)
|
||||
carbohydrates_grams = Column(Float, nullable=True)
|
||||
fat_grams = Column(Float, nullable=True)
|
||||
fiber_grams = Column(Float, nullable=True)
|
||||
sugar_grams = Column(Float, nullable=True)
|
||||
|
||||
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_nutrition_record'),
|
||||
Index('ix_nutrition_user_date', 'user_id', 'start_time'),
|
||||
)
|
||||
|
||||
|
||||
# ==================== HYDRATION ====================
|
||||
|
||||
class HCRawHydration(Base):
|
||||
"""Hydration records from Health Connect HydrationRecord"""
|
||||
__tablename__ = "hc_raw_hydration"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
|
||||
record_id = Column(String(100), index=True)
|
||||
data_source = Column(String(200))
|
||||
data_source_name = Column(String(100))
|
||||
|
||||
start_time = Column(DateTime, index=True)
|
||||
end_time = Column(DateTime)
|
||||
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
volume_liters = Column(Float)
|
||||
|
||||
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_hydration_record'),
|
||||
Index('ix_hydration_user_date', 'user_id', 'start_time'),
|
||||
)
|
||||
|
||||
|
||||
# ==================== HEART RATE VARIABILITY ====================
|
||||
|
||||
class HCRawHeartRateVariabilityRmssd(Base):
|
||||
"""HRV records from Health Connect HeartRateVariabilityRmssdRecord"""
|
||||
__tablename__ = "hc_raw_hrv_rmssd"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
|
||||
record_id = Column(String(100), index=True)
|
||||
data_source = Column(String(200))
|
||||
data_source_name = Column(String(100))
|
||||
|
||||
time = Column(DateTime, index=True)
|
||||
synced_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
rmssd = Column(Float)
|
||||
|
||||
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_hrv_rmssd_record'),
|
||||
Index('ix_hrv_rmssd_user_date', 'user_id', 'time'),
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Tracking SQLAlchemy model."""
|
||||
from sqlalchemy import Column, Integer, Date, Boolean, Text, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from backend.database import Base
|
||||
|
||||
|
||||
class Tracking(Base):
|
||||
"""Tracking model for recording challenge progress."""
|
||||
__tablename__ = "tracking"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
challenge_id = Column(Integer, ForeignKey("challenges.id"))
|
||||
date = Column(Date)
|
||||
reps = Column(Integer, default=0)
|
||||
completed = Column(Boolean, default=False)
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
challenge = relationship("Challenge", back_populates="trackings")
|
||||
@@ -0,0 +1,17 @@
|
||||
"""User SQLAlchemy model."""
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
from backend.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User model for authentication and ownership."""
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String, unique=True, index=True)
|
||||
hashed_password = Column(String)
|
||||
|
||||
challenges = relationship("Challenge", back_populates="owner")
|
||||
health_metrics = relationship("DailyHealthMetrics", back_populates="user")
|
||||
workouts = relationship("WorkoutSession", back_populates="user")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""WorkoutSession SQLAlchemy model."""
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from backend.database import Base
|
||||
|
||||
|
||||
# Zepp activity type mapping
|
||||
ZEPP_ACTIVITY_TYPES = {
|
||||
1: "Running",
|
||||
6: "Walking",
|
||||
8: "Cycling",
|
||||
10: "Elliptical",
|
||||
16: "Free Training",
|
||||
23: "HIIT",
|
||||
52: "Stretching",
|
||||
60: "Yoga",
|
||||
}
|
||||
|
||||
|
||||
class WorkoutSession(Base):
|
||||
"""Workout data model."""
|
||||
__tablename__ = "workout_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
start_time = Column(DateTime, index=True)
|
||||
end_time = Column(DateTime)
|
||||
activity_type = Column(String) # Human readable name
|
||||
activity_type_id = Column(Integer, nullable=True) # Original Zepp type ID
|
||||
duration_seconds = Column(Integer, default=0)
|
||||
distance_meters = Column(Float, default=0.0)
|
||||
calories = Column(Float, default=0.0)
|
||||
avg_pace = Column(Float, nullable=True) # seconds per meter
|
||||
max_pace = Column(Float, nullable=True)
|
||||
min_pace = Column(Float, nullable=True)
|
||||
avg_hr = Column(Integer, nullable=True)
|
||||
max_hr = Column(Integer, nullable=True)
|
||||
min_hr = Column(Integer, nullable=True)
|
||||
notes = Column(String, nullable=True)
|
||||
|
||||
# Data provenance tracking
|
||||
data_source = Column(String(50), default="health_connect") # "health_connect", "manual"
|
||||
|
||||
user = relationship("User", back_populates="workouts")
|
||||
|
||||
@staticmethod
|
||||
def get_activity_name(type_id: int) -> str:
|
||||
return ZEPP_ACTIVITY_TYPES.get(type_id, f"Activity {type_id}")
|
||||
@@ -0,0 +1 @@
|
||||
"""Repositories module for data access layer."""
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Base repository for common database operations."""
|
||||
from typing import Generic, TypeVar, Type, List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
ModelType = TypeVar("ModelType")
|
||||
|
||||
|
||||
class BaseRepository(Generic[ModelType]):
|
||||
"""Base repository with common CRUD operations."""
|
||||
|
||||
def __init__(self, model: Type[ModelType], db: Session):
|
||||
"""Initialize repository with model and database session."""
|
||||
self.model = model
|
||||
self.db = db
|
||||
|
||||
def get_by_id(self, id: int) -> Optional[ModelType]:
|
||||
"""Get a single record by ID."""
|
||||
return self.db.query(self.model).filter(self.model.id == id).first()
|
||||
|
||||
def get_all(self) -> List[ModelType]:
|
||||
"""Get all records."""
|
||||
return self.db.query(self.model).all()
|
||||
|
||||
def create(self, **kwargs) -> ModelType:
|
||||
"""Create a new record."""
|
||||
db_obj = self.model(**kwargs)
|
||||
self.db.add(db_obj)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def update(self, id: int, **kwargs) -> Optional[ModelType]:
|
||||
"""Update a record by ID."""
|
||||
db_obj = self.get_by_id(id)
|
||||
if db_obj:
|
||||
for key, value in kwargs.items():
|
||||
setattr(db_obj, key, value)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def delete(self, id: int) -> bool:
|
||||
"""Delete a record by ID."""
|
||||
db_obj = self.get_by_id(id)
|
||||
if db_obj:
|
||||
self.db.delete(db_obj)
|
||||
self.db.commit()
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Challenge repository for data access operations."""
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from backend.models.challenge import Challenge
|
||||
from backend.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class ChallengeRepository(BaseRepository[Challenge]):
|
||||
"""Repository for Challenge model operations."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
"""Initialize challenge repository."""
|
||||
super().__init__(Challenge, db)
|
||||
|
||||
def get_by_user_id(self, user_id: int) -> List[Challenge]:
|
||||
"""Get all challenges for a user ordered by display_order."""
|
||||
return self.db.query(Challenge).filter(
|
||||
Challenge.user_id == user_id
|
||||
).order_by(Challenge.display_order.asc()).all()
|
||||
|
||||
def get_by_user_and_id(self, user_id: int, challenge_id: int) -> Optional[Challenge]:
|
||||
"""Get a challenge by user ID and challenge ID."""
|
||||
return self.db.query(Challenge).filter(
|
||||
Challenge.id == challenge_id,
|
||||
Challenge.user_id == user_id
|
||||
).first()
|
||||
|
||||
def get_max_display_order(self, user_id: int) -> Optional[int]:
|
||||
"""Get the maximum display_order for a user."""
|
||||
return self.db.query(func.max(Challenge.display_order)).filter(
|
||||
Challenge.user_id == user_id
|
||||
).scalar()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""User repository for data access operations."""
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from backend.models.user import User
|
||||
from backend.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class UserRepository(BaseRepository[User]):
|
||||
"""Repository for User model operations."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
"""Initialize user repository."""
|
||||
super().__init__(User, db)
|
||||
|
||||
def get_by_username(self, username: str) -> Optional[User]:
|
||||
"""Get a user by username."""
|
||||
return self.db.query(User).filter(User.username == username).first()
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Pydantic schemas module."""
|
||||
from backend.schemas.user import UserCreate, UserResponse
|
||||
from backend.schemas.challenge import ChallengeBase, ChallengeCreate, ChallengeResponse
|
||||
from backend.schemas.tracking import TrackingBase, TrackingCreate, TrackingResponse
|
||||
from backend.schemas.daily_health_metrics import DailyHealthMetricsBase, DailyHealthMetricsResponse
|
||||
from backend.schemas.workout_session import WorkoutSessionBase, WorkoutSessionCreate, WorkoutSessionResponse
|
||||
from backend.schemas.token import Token
|
||||
|
||||
__all__ = [
|
||||
"UserCreate", "UserResponse",
|
||||
"ChallengeBase", "ChallengeCreate", "ChallengeResponse",
|
||||
"TrackingBase", "TrackingCreate", "TrackingResponse",
|
||||
"DailyHealthMetricsBase", "DailyHealthMetricsResponse",
|
||||
"WorkoutSessionBase", "WorkoutSessionCreate", "WorkoutSessionResponse",
|
||||
"Token"
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Challenge Pydantic schemas."""
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import date
|
||||
from backend.schemas.tracking import TrackingResponse
|
||||
from backend.models.tracking import Tracking
|
||||
|
||||
|
||||
class ChallengeBase(BaseModel):
|
||||
"""Base schema for challenge."""
|
||||
name: str
|
||||
period_type: str
|
||||
daily_target: int
|
||||
start_date: date
|
||||
end_date: Optional[date] = None
|
||||
unit_type: Optional[str] = "reps"
|
||||
rest_days: Optional[str] = None
|
||||
icon: Optional[str] = "fa-dumbbell"
|
||||
image_url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
frequency_count: Optional[int] = None
|
||||
selected_days: Optional[str] = None
|
||||
|
||||
|
||||
class ChallengeCreate(ChallengeBase):
|
||||
"""Schema for creating a challenge."""
|
||||
pass
|
||||
|
||||
|
||||
class ChallengeResponse(ChallengeBase):
|
||||
"""Schema for challenge response."""
|
||||
id: int
|
||||
created_at: date
|
||||
display_order: int = 0
|
||||
trackings: List[TrackingResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,34 @@
|
||||
"""DailyHealthMetrics Pydantic schemas."""
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import date
|
||||
|
||||
|
||||
class DailyHealthMetricsBase(BaseModel):
|
||||
"""Base schema for daily health metrics."""
|
||||
date: date
|
||||
step_count: Optional[int] = 0
|
||||
distance_meters: Optional[float] = 0.0
|
||||
calories_burned: Optional[float] = 0.0
|
||||
sleep_duration_minutes: Optional[int] = 0
|
||||
deep_sleep_minutes: Optional[int] = 0
|
||||
light_sleep_minutes: Optional[int] = 0
|
||||
rem_sleep_minutes: Optional[int] = 0
|
||||
awake_duration_minutes: Optional[int] = 0
|
||||
avg_heart_rate: Optional[int] = None
|
||||
min_heart_rate: Optional[int] = None
|
||||
max_heart_rate: Optional[int] = None
|
||||
avg_spo2: Optional[float] = None
|
||||
pai_score: Optional[float] = 0.0
|
||||
weight: Optional[float] = None
|
||||
resting_heart_rate: Optional[int] = None
|
||||
hrv: Optional[float] = None
|
||||
|
||||
|
||||
class DailyHealthMetricsResponse(DailyHealthMetricsBase):
|
||||
"""Schema for daily health metrics response."""
|
||||
id: int
|
||||
user_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Token Pydantic schema."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Schema for JWT token response."""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Tracking Pydantic schemas."""
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import date
|
||||
|
||||
|
||||
class TrackingBase(BaseModel):
|
||||
"""Base schema for tracking."""
|
||||
date: date
|
||||
reps: int
|
||||
completed: bool = False
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class TrackingCreate(TrackingBase):
|
||||
"""Schema for creating tracking."""
|
||||
challenge_id: int
|
||||
|
||||
|
||||
class TrackingResponse(TrackingBase):
|
||||
"""Schema for tracking response."""
|
||||
id: int
|
||||
challenge_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,17 @@
|
||||
"""User Pydantic schemas."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""Schema for user registration."""
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""Schema for user response."""
|
||||
id: int
|
||||
username: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,36 @@
|
||||
"""WorkoutSession Pydantic schemas."""
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class WorkoutSessionBase(BaseModel):
|
||||
"""Base schema for workout session."""
|
||||
start_time: datetime
|
||||
end_time: Optional[datetime] = None
|
||||
activity_type: str
|
||||
activity_type_id: Optional[int] = None
|
||||
duration_seconds: int = 0
|
||||
distance_meters: float = 0.0
|
||||
calories: float = 0.0
|
||||
avg_pace: Optional[float] = None
|
||||
max_pace: Optional[float] = None
|
||||
min_pace: Optional[float] = None
|
||||
avg_hr: Optional[int] = None
|
||||
max_hr: Optional[int] = None
|
||||
min_hr: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class WorkoutSessionCreate(WorkoutSessionBase):
|
||||
"""Schema for creating a workout session."""
|
||||
pass
|
||||
|
||||
|
||||
class WorkoutSessionResponse(WorkoutSessionBase):
|
||||
"""Schema for workout session response."""
|
||||
id: int
|
||||
user_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1 @@
|
||||
"""Services module for business logic."""
|
||||
@@ -0,0 +1,497 @@
|
||||
"""
|
||||
Aggregation Service - Computes aggregated metrics from raw Health Connect data.
|
||||
|
||||
This service calculates daily, weekly, and monthly aggregations on-demand
|
||||
from the raw health records stored in the database.
|
||||
"""
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from backend.models.health_connect_raw import (
|
||||
HCRawSteps,
|
||||
HCRawHeartRate,
|
||||
HCRawHeartRateSample,
|
||||
HCRawRestingHeartRate,
|
||||
HCRawSleepSession,
|
||||
HCRawSleepStage,
|
||||
HCRawDistance,
|
||||
HCRawCalories,
|
||||
HCRawOxygenSaturation,
|
||||
HCRawWeight,
|
||||
HCRawExerciseSession,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DailyMetrics:
|
||||
"""Aggregated daily health metrics"""
|
||||
date: date
|
||||
step_count: int = 0
|
||||
distance_meters: float = 0.0
|
||||
calories_burned: float = 0.0
|
||||
active_calories: float = 0.0
|
||||
|
||||
# Sleep
|
||||
sleep_duration_minutes: int = 0
|
||||
deep_sleep_minutes: int = 0
|
||||
light_sleep_minutes: int = 0
|
||||
rem_sleep_minutes: int = 0
|
||||
awake_minutes: int = 0
|
||||
|
||||
# Heart rate
|
||||
avg_heart_rate: Optional[int] = None
|
||||
min_heart_rate: Optional[int] = None
|
||||
max_heart_rate: Optional[int] = None
|
||||
resting_heart_rate: Optional[int] = None
|
||||
|
||||
# SpO2
|
||||
avg_spo2: Optional[float] = None
|
||||
min_spo2: Optional[float] = None
|
||||
max_spo2: Optional[float] = None
|
||||
|
||||
# Body
|
||||
weight: Optional[float] = None
|
||||
|
||||
# Counts
|
||||
exercise_count: int = 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"date": self.date.isoformat(),
|
||||
"step_count": self.step_count,
|
||||
"distance_meters": self.distance_meters,
|
||||
"calories_burned": self.calories_burned,
|
||||
"active_calories": self.active_calories,
|
||||
"sleep_duration_minutes": self.sleep_duration_minutes,
|
||||
"deep_sleep_minutes": self.deep_sleep_minutes,
|
||||
"light_sleep_minutes": self.light_sleep_minutes,
|
||||
"rem_sleep_minutes": self.rem_sleep_minutes,
|
||||
"awake_minutes": self.awake_minutes,
|
||||
"avg_heart_rate": self.avg_heart_rate,
|
||||
"min_heart_rate": self.min_heart_rate,
|
||||
"max_heart_rate": self.max_heart_rate,
|
||||
"resting_heart_rate": self.resting_heart_rate,
|
||||
"avg_spo2": self.avg_spo2,
|
||||
"min_spo2": self.min_spo2,
|
||||
"max_spo2": self.max_spo2,
|
||||
"weight": self.weight,
|
||||
"exercise_count": self.exercise_count,
|
||||
}
|
||||
|
||||
|
||||
class AggregationService:
|
||||
"""
|
||||
Service for computing aggregated metrics from raw Health Connect data.
|
||||
|
||||
All methods take a user_id and return computed metrics based on the
|
||||
raw records stored in the database.
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
# ==================== DAILY AGGREGATIONS ====================
|
||||
|
||||
def get_daily_steps(self, user_id: int, target_date: date) -> int:
|
||||
"""Sum all step records for a given day."""
|
||||
start_of_day = datetime.combine(target_date, datetime.min.time())
|
||||
end_of_day = datetime.combine(target_date + timedelta(days=1), datetime.min.time())
|
||||
|
||||
result = self.db.query(func.sum(HCRawSteps.count)).filter(
|
||||
HCRawSteps.user_id == user_id,
|
||||
HCRawSteps.start_time >= start_of_day,
|
||||
HCRawSteps.start_time < end_of_day
|
||||
).scalar()
|
||||
|
||||
return result or 0
|
||||
|
||||
def get_daily_distance(self, user_id: int, target_date: date) -> float:
|
||||
"""Sum all distance records for a given day."""
|
||||
start_of_day = datetime.combine(target_date, datetime.min.time())
|
||||
end_of_day = datetime.combine(target_date + timedelta(days=1), datetime.min.time())
|
||||
|
||||
result = self.db.query(func.sum(HCRawDistance.distance_meters)).filter(
|
||||
HCRawDistance.user_id == user_id,
|
||||
HCRawDistance.start_time >= start_of_day,
|
||||
HCRawDistance.start_time < end_of_day
|
||||
).scalar()
|
||||
|
||||
return result or 0.0
|
||||
|
||||
def get_daily_calories(self, user_id: int, target_date: date) -> Dict[str, float]:
|
||||
"""Get calories burned for a given day (active and total)."""
|
||||
start_of_day = datetime.combine(target_date, datetime.min.time())
|
||||
end_of_day = datetime.combine(target_date + timedelta(days=1), datetime.min.time())
|
||||
|
||||
# Active calories
|
||||
active = self.db.query(func.sum(HCRawCalories.calories)).filter(
|
||||
HCRawCalories.user_id == user_id,
|
||||
HCRawCalories.start_time >= start_of_day,
|
||||
HCRawCalories.start_time < end_of_day,
|
||||
HCRawCalories.record_type == "active"
|
||||
).scalar() or 0.0
|
||||
|
||||
# Total calories
|
||||
total = self.db.query(func.sum(HCRawCalories.calories)).filter(
|
||||
HCRawCalories.user_id == user_id,
|
||||
HCRawCalories.start_time >= start_of_day,
|
||||
HCRawCalories.start_time < end_of_day,
|
||||
HCRawCalories.record_type == "total"
|
||||
).scalar() or 0.0
|
||||
|
||||
# If no total, use active as total
|
||||
if total == 0 and active > 0:
|
||||
total = active
|
||||
|
||||
return {"active": active, "total": total}
|
||||
|
||||
def get_daily_heart_rate_stats(self, user_id: int, target_date: date) -> Dict[str, Optional[int]]:
|
||||
"""Calculate min, max, avg heart rate for a day from all samples."""
|
||||
start_of_day = datetime.combine(target_date, datetime.min.time())
|
||||
end_of_day = datetime.combine(target_date + timedelta(days=1), datetime.min.time())
|
||||
|
||||
# Get all heart rate records for the day
|
||||
hr_records = self.db.query(HCRawHeartRate).filter(
|
||||
HCRawHeartRate.user_id == user_id,
|
||||
HCRawHeartRate.start_time >= start_of_day,
|
||||
HCRawHeartRate.start_time < end_of_day
|
||||
).all()
|
||||
|
||||
if not hr_records:
|
||||
return {"avg": None, "min": None, "max": None}
|
||||
|
||||
# Collect all samples
|
||||
all_bpm = []
|
||||
for record in hr_records:
|
||||
for sample in record.samples:
|
||||
if sample.bpm and sample.bpm > 0:
|
||||
all_bpm.append(sample.bpm)
|
||||
|
||||
if not all_bpm:
|
||||
return {"avg": None, "min": None, "max": None}
|
||||
|
||||
return {
|
||||
"avg": int(sum(all_bpm) / len(all_bpm)),
|
||||
"min": min(all_bpm),
|
||||
"max": max(all_bpm)
|
||||
}
|
||||
|
||||
def get_daily_resting_heart_rate(self, user_id: int, target_date: date) -> Optional[int]:
|
||||
"""Get the resting heart rate for a day (last recorded value)."""
|
||||
start_of_day = datetime.combine(target_date, datetime.min.time())
|
||||
end_of_day = datetime.combine(target_date + timedelta(days=1), datetime.min.time())
|
||||
|
||||
record = self.db.query(HCRawRestingHeartRate).filter(
|
||||
HCRawRestingHeartRate.user_id == user_id,
|
||||
HCRawRestingHeartRate.time >= start_of_day,
|
||||
HCRawRestingHeartRate.time < end_of_day
|
||||
).order_by(HCRawRestingHeartRate.time.desc()).first()
|
||||
|
||||
return record.bpm if record else None
|
||||
|
||||
def get_daily_sleep_stats(self, user_id: int, target_date: date) -> Dict[str, int]:
|
||||
"""
|
||||
Get sleep statistics for a day.
|
||||
|
||||
Sleep is attributed to the day you wake up (e.g., sleep from 11pm Jan 7
|
||||
to 7am Jan 8 is counted for Jan 8).
|
||||
"""
|
||||
start_of_day = datetime.combine(target_date, datetime.min.time())
|
||||
end_of_day = datetime.combine(target_date + timedelta(days=1), datetime.min.time())
|
||||
|
||||
# Find sleep sessions that ended on this day
|
||||
sessions = self.db.query(HCRawSleepSession).filter(
|
||||
HCRawSleepSession.user_id == user_id,
|
||||
HCRawSleepSession.end_time >= start_of_day,
|
||||
HCRawSleepSession.end_time < end_of_day
|
||||
).all()
|
||||
|
||||
total_minutes = 0
|
||||
deep_minutes = 0
|
||||
light_minutes = 0
|
||||
rem_minutes = 0
|
||||
awake_minutes = 0
|
||||
|
||||
for session in sessions:
|
||||
# Total sleep duration
|
||||
if session.start_time and session.end_time:
|
||||
duration = (session.end_time - session.start_time).total_seconds() / 60
|
||||
total_minutes += int(duration)
|
||||
|
||||
# Stage breakdown
|
||||
for stage in session.stages:
|
||||
if stage.start_time and stage.end_time:
|
||||
stage_duration = (stage.end_time - stage.start_time).total_seconds() / 60
|
||||
stage_minutes = int(stage_duration)
|
||||
|
||||
if stage.stage_name in ("deep", "STAGE_TYPE_DEEP"):
|
||||
deep_minutes += stage_minutes
|
||||
elif stage.stage_name in ("light", "STAGE_TYPE_LIGHT"):
|
||||
light_minutes += stage_minutes
|
||||
elif stage.stage_name in ("rem", "STAGE_TYPE_REM"):
|
||||
rem_minutes += stage_minutes
|
||||
elif stage.stage_name in ("awake", "awake_in_bed", "STAGE_TYPE_AWAKE"):
|
||||
awake_minutes += stage_minutes
|
||||
|
||||
return {
|
||||
"total": total_minutes,
|
||||
"deep": deep_minutes,
|
||||
"light": light_minutes,
|
||||
"rem": rem_minutes,
|
||||
"awake": awake_minutes
|
||||
}
|
||||
|
||||
def get_daily_spo2_stats(self, user_id: int, target_date: date) -> Dict[str, Optional[float]]:
|
||||
"""Get SpO2 statistics for a day."""
|
||||
start_of_day = datetime.combine(target_date, datetime.min.time())
|
||||
end_of_day = datetime.combine(target_date + timedelta(days=1), datetime.min.time())
|
||||
|
||||
records = self.db.query(HCRawOxygenSaturation).filter(
|
||||
HCRawOxygenSaturation.user_id == user_id,
|
||||
HCRawOxygenSaturation.time >= start_of_day,
|
||||
HCRawOxygenSaturation.time < end_of_day
|
||||
).all()
|
||||
|
||||
if not records:
|
||||
return {"avg": None, "min": None, "max": None}
|
||||
|
||||
values = [r.percentage for r in records if r.percentage]
|
||||
if not values:
|
||||
return {"avg": None, "min": None, "max": None}
|
||||
|
||||
return {
|
||||
"avg": sum(values) / len(values),
|
||||
"min": min(values),
|
||||
"max": max(values)
|
||||
}
|
||||
|
||||
def get_daily_weight(self, user_id: int, target_date: date) -> Optional[float]:
|
||||
"""Get the latest weight recorded on a day."""
|
||||
start_of_day = datetime.combine(target_date, datetime.min.time())
|
||||
end_of_day = datetime.combine(target_date + timedelta(days=1), datetime.min.time())
|
||||
|
||||
record = self.db.query(HCRawWeight).filter(
|
||||
HCRawWeight.user_id == user_id,
|
||||
HCRawWeight.time >= start_of_day,
|
||||
HCRawWeight.time < end_of_day
|
||||
).order_by(HCRawWeight.time.desc()).first()
|
||||
|
||||
return record.weight_kg if record else None
|
||||
|
||||
def get_daily_exercise_count(self, user_id: int, target_date: date) -> int:
|
||||
"""Count exercise sessions for a day."""
|
||||
start_of_day = datetime.combine(target_date, datetime.min.time())
|
||||
end_of_day = datetime.combine(target_date + timedelta(days=1), datetime.min.time())
|
||||
|
||||
return self.db.query(func.count(HCRawExerciseSession.id)).filter(
|
||||
HCRawExerciseSession.user_id == user_id,
|
||||
HCRawExerciseSession.start_time >= start_of_day,
|
||||
HCRawExerciseSession.start_time < end_of_day
|
||||
).scalar() or 0
|
||||
|
||||
# ==================== COMBINED DAILY METRICS ====================
|
||||
|
||||
def get_daily_metrics(self, user_id: int, target_date: date) -> DailyMetrics:
|
||||
"""Get all aggregated metrics for a single day."""
|
||||
metrics = DailyMetrics(date=target_date)
|
||||
|
||||
# Steps and Activity
|
||||
metrics.step_count = self.get_daily_steps(user_id, target_date)
|
||||
metrics.distance_meters = self.get_daily_distance(user_id, target_date)
|
||||
|
||||
calories = self.get_daily_calories(user_id, target_date)
|
||||
metrics.calories_burned = calories["total"]
|
||||
metrics.active_calories = calories["active"]
|
||||
|
||||
# Heart Rate
|
||||
hr_stats = self.get_daily_heart_rate_stats(user_id, target_date)
|
||||
metrics.avg_heart_rate = hr_stats["avg"]
|
||||
metrics.min_heart_rate = hr_stats["min"]
|
||||
metrics.max_heart_rate = hr_stats["max"]
|
||||
metrics.resting_heart_rate = self.get_daily_resting_heart_rate(user_id, target_date)
|
||||
|
||||
# Sleep
|
||||
sleep_stats = self.get_daily_sleep_stats(user_id, target_date)
|
||||
metrics.sleep_duration_minutes = sleep_stats["total"]
|
||||
metrics.deep_sleep_minutes = sleep_stats["deep"]
|
||||
metrics.light_sleep_minutes = sleep_stats["light"]
|
||||
metrics.rem_sleep_minutes = sleep_stats["rem"]
|
||||
metrics.awake_minutes = sleep_stats["awake"]
|
||||
|
||||
# SpO2
|
||||
spo2_stats = self.get_daily_spo2_stats(user_id, target_date)
|
||||
metrics.avg_spo2 = spo2_stats["avg"]
|
||||
metrics.min_spo2 = spo2_stats["min"]
|
||||
metrics.max_spo2 = spo2_stats["max"]
|
||||
|
||||
# Body
|
||||
metrics.weight = self.get_daily_weight(user_id, target_date)
|
||||
|
||||
# Exercise
|
||||
metrics.exercise_count = self.get_daily_exercise_count(user_id, target_date)
|
||||
|
||||
return metrics
|
||||
|
||||
# ==================== RANGE AGGREGATIONS ====================
|
||||
|
||||
def get_metrics_for_range(
|
||||
self,
|
||||
user_id: int,
|
||||
start_date: date,
|
||||
end_date: date
|
||||
) -> List[DailyMetrics]:
|
||||
"""Get daily metrics for a date range."""
|
||||
metrics = []
|
||||
current_date = start_date
|
||||
|
||||
while current_date <= end_date:
|
||||
daily = self.get_daily_metrics(user_id, current_date)
|
||||
metrics.append(daily)
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
return metrics
|
||||
|
||||
def get_weekly_summary(self, user_id: int, ref_date: date) -> Dict[str, Any]:
|
||||
"""Get aggregated summary for a week (7 days ending on ref_date)."""
|
||||
start_date = ref_date - timedelta(days=6)
|
||||
daily_metrics = self.get_metrics_for_range(user_id, start_date, ref_date)
|
||||
|
||||
# Aggregate
|
||||
total_steps = sum(m.step_count for m in daily_metrics)
|
||||
total_distance = sum(m.distance_meters for m in daily_metrics)
|
||||
total_calories = sum(m.calories_burned for m in daily_metrics)
|
||||
|
||||
# Average heart rate (excluding days with no data)
|
||||
hr_values = [m.avg_heart_rate for m in daily_metrics if m.avg_heart_rate]
|
||||
avg_hr = int(sum(hr_values) / len(hr_values)) if hr_values else None
|
||||
|
||||
# Average sleep
|
||||
sleep_values = [m.sleep_duration_minutes for m in daily_metrics if m.sleep_duration_minutes > 0]
|
||||
avg_sleep = int(sum(sleep_values) / len(sleep_values)) if sleep_values else 0
|
||||
|
||||
# Latest weight
|
||||
weights = [m.weight for m in daily_metrics if m.weight]
|
||||
latest_weight = weights[-1] if weights else None
|
||||
|
||||
return {
|
||||
"period": "week",
|
||||
"start_date": start_date.isoformat(),
|
||||
"end_date": ref_date.isoformat(),
|
||||
"days_with_data": len([m for m in daily_metrics if m.step_count > 0]),
|
||||
"totals": {
|
||||
"steps": total_steps,
|
||||
"distance_meters": total_distance,
|
||||
"calories_burned": total_calories,
|
||||
"exercise_count": sum(m.exercise_count for m in daily_metrics),
|
||||
},
|
||||
"averages": {
|
||||
"steps_per_day": total_steps // 7,
|
||||
"distance_per_day": total_distance / 7,
|
||||
"calories_per_day": total_calories / 7,
|
||||
"sleep_minutes": avg_sleep,
|
||||
"heart_rate": avg_hr,
|
||||
},
|
||||
"latest": {
|
||||
"weight": latest_weight,
|
||||
},
|
||||
"daily": [m.to_dict() for m in daily_metrics]
|
||||
}
|
||||
|
||||
def get_monthly_summary(self, user_id: int, ref_date: date) -> Dict[str, Any]:
|
||||
"""Get aggregated summary for a month (30 days ending on ref_date)."""
|
||||
start_date = ref_date - timedelta(days=29)
|
||||
daily_metrics = self.get_metrics_for_range(user_id, start_date, ref_date)
|
||||
|
||||
# Aggregate
|
||||
total_steps = sum(m.step_count for m in daily_metrics)
|
||||
total_distance = sum(m.distance_meters for m in daily_metrics)
|
||||
total_calories = sum(m.calories_burned for m in daily_metrics)
|
||||
|
||||
days_count = len(daily_metrics)
|
||||
|
||||
return {
|
||||
"period": "month",
|
||||
"start_date": start_date.isoformat(),
|
||||
"end_date": ref_date.isoformat(),
|
||||
"days_with_data": len([m for m in daily_metrics if m.step_count > 0]),
|
||||
"totals": {
|
||||
"steps": total_steps,
|
||||
"distance_meters": total_distance,
|
||||
"calories_burned": total_calories,
|
||||
"exercise_count": sum(m.exercise_count for m in daily_metrics),
|
||||
},
|
||||
"averages": {
|
||||
"steps_per_day": total_steps // days_count if days_count > 0 else 0,
|
||||
"distance_per_day": total_distance / days_count if days_count > 0 else 0,
|
||||
"calories_per_day": total_calories / days_count if days_count > 0 else 0,
|
||||
},
|
||||
}
|
||||
|
||||
# ==================== TODAY'S METRICS (CURRENT/LIVE) ====================
|
||||
|
||||
def get_today_metrics(self, user_id: int) -> DailyMetrics:
|
||||
"""
|
||||
Get today's metrics - useful for dashboard display.
|
||||
This returns the most up-to-date aggregation for the current day.
|
||||
"""
|
||||
return self.get_daily_metrics(user_id, date.today())
|
||||
|
||||
# ==================== METRIC HISTORY ====================
|
||||
|
||||
def get_metric_history(
|
||||
self,
|
||||
user_id: int,
|
||||
metric_type: str,
|
||||
start_date: date,
|
||||
end_date: date
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get historical data for a specific metric type.
|
||||
|
||||
Args:
|
||||
metric_type: One of "steps", "heart_rate", "sleep", "weight", "spo2"
|
||||
"""
|
||||
history = []
|
||||
current_date = start_date
|
||||
|
||||
while current_date <= end_date:
|
||||
data_point = {"date": current_date.isoformat()}
|
||||
|
||||
if metric_type == "steps":
|
||||
data_point["value"] = self.get_daily_steps(user_id, current_date)
|
||||
elif metric_type == "distance":
|
||||
data_point["value"] = self.get_daily_distance(user_id, current_date)
|
||||
elif metric_type == "calories":
|
||||
cals = self.get_daily_calories(user_id, current_date)
|
||||
data_point["value"] = cals["total"]
|
||||
data_point["active"] = cals["active"]
|
||||
elif metric_type == "heart_rate":
|
||||
hr = self.get_daily_heart_rate_stats(user_id, current_date)
|
||||
data_point["avg"] = hr["avg"]
|
||||
data_point["min"] = hr["min"]
|
||||
data_point["max"] = hr["max"]
|
||||
data_point["resting"] = self.get_daily_resting_heart_rate(user_id, current_date)
|
||||
elif metric_type == "sleep":
|
||||
sleep = self.get_daily_sleep_stats(user_id, current_date)
|
||||
data_point["total"] = sleep["total"]
|
||||
data_point["deep"] = sleep["deep"]
|
||||
data_point["light"] = sleep["light"]
|
||||
data_point["rem"] = sleep["rem"]
|
||||
elif metric_type == "weight":
|
||||
data_point["value"] = self.get_daily_weight(user_id, current_date)
|
||||
elif metric_type == "spo2":
|
||||
spo2 = self.get_daily_spo2_stats(user_id, current_date)
|
||||
data_point["avg"] = spo2["avg"]
|
||||
data_point["min"] = spo2["min"]
|
||||
data_point["max"] = spo2["max"]
|
||||
|
||||
history.append(data_point)
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
return history
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Authentication service for business logic."""
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from backend.models.user import User
|
||||
from backend import auth
|
||||
from backend.schemas.user import UserCreate
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Service for authentication operations."""
|
||||
|
||||
@staticmethod
|
||||
def register_user(db: Session, user_data: UserCreate) -> User:
|
||||
"""Register a new user."""
|
||||
db_user = db.query(User).filter(
|
||||
User.username == user_data.username
|
||||
).first()
|
||||
if db_user:
|
||||
raise ValueError("Username already registered")
|
||||
|
||||
hashed_password = auth.get_password_hash(user_data.password)
|
||||
new_user = models.User(
|
||||
username=user_data.username,
|
||||
hashed_password=hashed_password
|
||||
)
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
return new_user
|
||||
|
||||
@staticmethod
|
||||
def authenticate_user(db: Session, username: str, password: str) -> Optional[User]:
|
||||
"""Authenticate a user with username and password."""
|
||||
user = db.query(User).filter(
|
||||
User.username == username
|
||||
).first()
|
||||
if not user or not auth.verify_password(password, user.hashed_password):
|
||||
return None
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def create_access_token(username: str) -> str:
|
||||
"""Create a JWT access token for a user."""
|
||||
from datetime import timedelta
|
||||
from backend.core.config import settings
|
||||
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = auth.create_access_token(
|
||||
data={"sub": username},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
return access_token
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Challenge service for business logic."""
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from backend.models.challenge import Challenge
|
||||
from backend.schemas.challenge import ChallengeCreate
|
||||
|
||||
|
||||
class ChallengeService:
|
||||
"""Service for challenge operations."""
|
||||
|
||||
@staticmethod
|
||||
def create_challenge(
|
||||
db: Session,
|
||||
challenge_data: ChallengeCreate,
|
||||
user_id: int
|
||||
) -> Challenge:
|
||||
"""Create a new challenge for a user."""
|
||||
# Calculate next display_order for THIS user
|
||||
max_order = db.query(func.max(Challenge.display_order)).filter(
|
||||
Challenge.user_id == user_id
|
||||
).scalar()
|
||||
new_order = (max_order if max_order is not None else -1) + 1
|
||||
|
||||
db_challenge = Challenge(
|
||||
**challenge_data.dict(),
|
||||
display_order=new_order,
|
||||
user_id=user_id
|
||||
)
|
||||
db.add(db_challenge)
|
||||
db.commit()
|
||||
db.refresh(db_challenge)
|
||||
return db_challenge
|
||||
|
||||
@staticmethod
|
||||
def get_user_challenges(
|
||||
db: Session,
|
||||
user_id: int
|
||||
) -> List[Challenge]:
|
||||
"""Get all challenges for a user."""
|
||||
challenges = db.query(Challenge).filter(
|
||||
Challenge.user_id == user_id
|
||||
).order_by(Challenge.display_order.asc()).all()
|
||||
return challenges
|
||||
|
||||
@staticmethod
|
||||
def reorder_challenges(
|
||||
db: Session,
|
||||
ordered_ids: List[int],
|
||||
user_id: int
|
||||
) -> None:
|
||||
"""Reorder challenges for a user."""
|
||||
for index, challenge_id in enumerate(ordered_ids):
|
||||
db.query(Challenge).filter(
|
||||
Challenge.id == challenge_id,
|
||||
Challenge.user_id == user_id
|
||||
).update({"display_order": index})
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def delete_challenge(
|
||||
db: Session,
|
||||
challenge_id: int,
|
||||
user_id: int
|
||||
) -> Optional[Challenge]:
|
||||
"""Delete a challenge for a user."""
|
||||
db_challenge = db.query(Challenge).filter(
|
||||
Challenge.id == challenge_id,
|
||||
Challenge.user_id == user_id
|
||||
).first()
|
||||
if db_challenge:
|
||||
db.delete(db_challenge)
|
||||
db.commit()
|
||||
return db_challenge
|
||||
|
||||
@staticmethod
|
||||
def get_challenge_by_id(
|
||||
db: Session,
|
||||
challenge_id: int,
|
||||
user_id: int
|
||||
) -> Optional[Challenge]:
|
||||
"""Get a challenge by ID for a user."""
|
||||
return db.query(Challenge).filter(
|
||||
Challenge.id == challenge_id,
|
||||
Challenge.user_id == user_id
|
||||
).first()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Health service for business logic."""
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
class HealthService:
|
||||
"""Service for health check operations."""
|
||||
|
||||
@staticmethod
|
||||
def check_database(db: Session) -> bool:
|
||||
"""Check if database connection is healthy."""
|
||||
try:
|
||||
db.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_health_status(db: Session) -> dict:
|
||||
"""Get the health status of the application."""
|
||||
db_healthy = HealthService.check_database(db)
|
||||
|
||||
return {
|
||||
"status": "healthy" if db_healthy else "unhealthy",
|
||||
"database": "connected" if db_healthy else "disconnected"
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
|
||||
import os
|
||||
import sys
|
||||
from sqlalchemy import create_engine, text
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
# Database connection
|
||||
DATABASE_URL = "postgresql://habitforge:habitforge@localhost:5432/habitforge"
|
||||
|
||||
def check_sync_status():
|
||||
try:
|
||||
engine = create_engine(DATABASE_URL)
|
||||
with engine.connect() as connection:
|
||||
print("\n=== SYNC STATUS REPORT ===")
|
||||
|
||||
# 1. Check Sync Logs
|
||||
print("\n--- Latest Sync Session ---")
|
||||
query_logs = text("""
|
||||
SELECT id, status, sync_timestamp, records_pushed, error_message
|
||||
FROM health_connect_sync_logs
|
||||
ORDER BY sync_timestamp DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
result_logs = connection.execute(query_logs).fetchone()
|
||||
|
||||
if result_logs:
|
||||
print(f"Sync ID: {result_logs[0]}")
|
||||
print(f"Status: {result_logs[1]}")
|
||||
print(f"Timestamp: {result_logs[2]}")
|
||||
print(f"Records Pushed: {result_logs[3]}")
|
||||
if result_logs[4]:
|
||||
print(f"Error: {result_logs[4]}")
|
||||
|
||||
sync_id = result_logs[0]
|
||||
else:
|
||||
print("No sync logs found.")
|
||||
return
|
||||
|
||||
# 2. Check Raw Tables
|
||||
tables = [
|
||||
"hc_raw_steps",
|
||||
"hc_raw_heart_rate",
|
||||
"hc_raw_resting_hr",
|
||||
"hc_raw_sleep_sessions",
|
||||
"hc_raw_distance",
|
||||
"hc_raw_calories",
|
||||
"hc_raw_weight",
|
||||
"hc_raw_spo2",
|
||||
"hc_raw_exercise_sessions",
|
||||
"hc_raw_nutrition",
|
||||
"hc_raw_hydration"
|
||||
]
|
||||
|
||||
print("\n--- Raw Data Counts (All Time) ---")
|
||||
for table in tables:
|
||||
try:
|
||||
# Check if table exists first (handling case where some tables might not be created yet)
|
||||
query_count = text(f"SELECT COUNT(*), MIN(start_time), MAX(start_time) FROM {table}")
|
||||
result_count = connection.execute(query_count).fetchone()
|
||||
count = result_count[0]
|
||||
min_time = result_count[1]
|
||||
max_time = result_count[2]
|
||||
print(f"{table}: {count} records (Latest: {max_time})")
|
||||
except Exception as e:
|
||||
connection.rollback()
|
||||
print(f"{table}: Error querying (might not exist or empty) - {str(e).splitlines()[0]}")
|
||||
|
||||
# 3. Specific check for the user's main concern: Steps from the latest sync
|
||||
print("\n--- Steps from Latest Sync ---")
|
||||
if sync_id:
|
||||
query_steps_sync = text(f"SELECT COUNT(*) FROM hc_raw_steps WHERE sync_id = :sync_id")
|
||||
steps_synced_count = connection.execute(query_steps_sync, {"sync_id": sync_id}).scalar()
|
||||
print(f"Steps added in last sync (ID {sync_id}): {steps_synced_count}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"CRITICAL ERROR: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_sync_status()
|
||||
Reference in New Issue
Block a user