Initial commit: HabitForge - habit tracking application with FastAPI backend, React frontend, and Zepp integration
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,63 @@
|
||||
"""Authentication endpoints for API v1."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
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
|
||||
)
|
||||
return {"access_token": access_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,215 @@
|
||||
"""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)}")
|
||||
|
||||
|
||||
@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."""
|
||||
if ref_date is None:
|
||||
ref_date = datetime.date.today()
|
||||
|
||||
if period == "day":
|
||||
return db.query(DailyHealthMetrics).filter(
|
||||
DailyHealthMetrics.user_id == current_user.id,
|
||||
DailyHealthMetrics.date == ref_date
|
||||
).all()
|
||||
|
||||
elif period == "week":
|
||||
start_date = ref_date - datetime.timedelta(days=6)
|
||||
return db.query(DailyHealthMetrics).filter(
|
||||
DailyHealthMetrics.user_id == current_user.id,
|
||||
DailyHealthMetrics.date.between(start_date, ref_date)
|
||||
).order_by(DailyHealthMetrics.date.asc()).all()
|
||||
|
||||
elif period == "month":
|
||||
start_date = ref_date - datetime.timedelta(days=29)
|
||||
raw_data = db.query(DailyHealthMetrics).filter(
|
||||
DailyHealthMetrics.user_id == current_user.id,
|
||||
DailyHealthMetrics.date.between(start_date, ref_date)
|
||||
).order_by(DailyHealthMetrics.date.asc()).all()
|
||||
|
||||
aggregated = []
|
||||
import itertools
|
||||
key_func = lambda x: x.date.isocalendar()[1]
|
||||
|
||||
for key, group in itertools.groupby(raw_data, key_func):
|
||||
g = list(group)
|
||||
first = g[0]
|
||||
agg = {
|
||||
"id": key,
|
||||
"user_id": current_user.id,
|
||||
"date": first.date,
|
||||
"step_count": sum(x.step_count for x in g) // len(g),
|
||||
"step_count_total": sum(x.step_count for x in g),
|
||||
"calories_burned": sum(x.calories_burned for x in g) / len(g),
|
||||
"distance_meters": sum(x.distance_meters for x in g) / len(g),
|
||||
"sleep_duration_minutes": sum(x.sleep_duration_minutes for x in g) // len(g),
|
||||
"deep_sleep_minutes": sum(x.deep_sleep_minutes for x in g) // len(g),
|
||||
"light_sleep_minutes": sum(getattr(x, 'light_sleep_minutes', 0) for x in g) // len(g),
|
||||
"rem_sleep_minutes": sum(getattr(x, 'rem_sleep_minutes', 0) for x in g) // len(g),
|
||||
"awake_duration_minutes": sum(x.awake_duration_minutes for x in g) // len(g),
|
||||
"pai_score": sum(x.pai_score for x in g) / len(g),
|
||||
"avg_heart_rate": sum(x.avg_heart_rate or 0 for x in g) // len(g),
|
||||
"avg_spo2": sum(x.avg_spo2 or 0 for x in g) / len(g),
|
||||
"min_heart_rate": min((x.min_heart_rate for x in g if x.min_heart_rate), default=0),
|
||||
"max_heart_rate": max((x.max_heart_rate for x in g if x.max_heart_rate), default=0)
|
||||
}
|
||||
aggregated.append(agg)
|
||||
|
||||
return aggregated
|
||||
|
||||
elif period == "year":
|
||||
start_date = datetime.date.today() - datetime.timedelta(days=365)
|
||||
raw_data = db.query(DailyHealthMetrics).filter(
|
||||
DailyHealthMetrics.user_id == current_user.id,
|
||||
DailyHealthMetrics.date >= start_date
|
||||
).order_by(DailyHealthMetrics.date.asc()).all()
|
||||
|
||||
aggregated = []
|
||||
import itertools
|
||||
key_func = lambda x: x.date.month
|
||||
|
||||
for key, group in itertools.groupby(raw_data, key_func):
|
||||
g = list(group)
|
||||
first = g[0]
|
||||
agg = {
|
||||
"id": key,
|
||||
"user_id": current_user.id,
|
||||
"date": first.date.replace(day=1),
|
||||
"step_count": sum(x.step_count for x in g) // len(g),
|
||||
"step_count_total": sum(x.step_count for x in g),
|
||||
"calories_burned": sum(x.calories_burned for x in g) / len(g),
|
||||
"distance_meters": sum(x.distance_meters for x in g) / len(g),
|
||||
"sleep_duration_minutes": sum(x.sleep_duration_minutes for x in g) // len(g),
|
||||
"deep_sleep_minutes": sum(x.deep_sleep_minutes for x in g) // len(g),
|
||||
"light_sleep_minutes": sum(getattr(x, 'light_sleep_minutes', 0) for x in g) // len(g),
|
||||
"rem_sleep_minutes": sum(getattr(x, 'rem_sleep_minutes', 0) for x in g) // len(g),
|
||||
"awake_duration_minutes": sum(x.awake_duration_minutes for x in g) // len(g),
|
||||
"pai_score": sum(x.pai_score for x in g) / len(g),
|
||||
"avg_heart_rate": sum(x.avg_heart_rate or 0 for x in g) // len(g),
|
||||
"avg_spo2": sum(x.avg_spo2 or 0 for x in g) / len(g),
|
||||
"min_heart_rate": min((x.min_heart_rate for x in g if x.min_heart_rate), default=0),
|
||||
"max_heart_rate": max((x.max_heart_rate for x in g if x.max_heart_rate), default=0)
|
||||
}
|
||||
aggregated.append(agg)
|
||||
|
||||
return aggregated
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/workouts")
|
||||
def get_workouts(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
activity_type: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""Retrieve workout sessions for the current user."""
|
||||
query = db.query(WorkoutSession).filter(
|
||||
WorkoutSession.user_id == current_user.id
|
||||
)
|
||||
|
||||
if activity_type:
|
||||
query = query.filter(WorkoutSession.activity_type == activity_type)
|
||||
|
||||
workouts = query.order_by(
|
||||
WorkoutSession.start_time.desc()
|
||||
).offset(offset).limit(limit).all()
|
||||
|
||||
# Manually serialize to avoid Pydantic issues
|
||||
return [
|
||||
{
|
||||
"id": 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
|
||||
}
|
||||
for w in workouts
|
||||
]
|
||||
|
||||
|
||||
@router.get("/workouts/summary")
|
||||
def get_workout_summary(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""Get summary statistics for workouts."""
|
||||
from sqlalchemy import func
|
||||
|
||||
# Total counts
|
||||
total = db.query(func.count(WorkoutSession.id)).filter(
|
||||
WorkoutSession.user_id == current_user.id
|
||||
).scalar()
|
||||
|
||||
# By activity type
|
||||
by_type = 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
|
||||
).group_by(WorkoutSession.activity_type).all()
|
||||
|
||||
return {
|
||||
"total_workouts": total,
|
||||
"by_activity_type": [
|
||||
{
|
||||
"activity_type": row[0],
|
||||
"count": row[1],
|
||||
"total_duration_seconds": row[2] or 0,
|
||||
"total_calories": row[3] or 0,
|
||||
"total_distance_meters": row[4] or 0
|
||||
}
|
||||
for row in by_type
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
"""
|
||||
Zepp API Router - Endpoints for Zepp/Amazfit data synchronization.
|
||||
|
||||
Provides endpoints for connecting to Zepp account and syncing health data.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import DailyHealthMetrics, User, WorkoutSession
|
||||
from backend.models.workout_session import ZEPP_ACTIVITY_TYPES
|
||||
from backend.integrations.zepp_api import ZeppApiClient, ZeppApiError, sync_zepp_data
|
||||
from backend import auth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/zepp", tags=["zepp"])
|
||||
|
||||
|
||||
# --- Request/Response Models ---
|
||||
|
||||
class ZeppConnectRequest(BaseModel):
|
||||
"""Request to connect a Zepp account."""
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class ZeppConnectResponse(BaseModel):
|
||||
"""Response after connecting Zepp account."""
|
||||
status: str
|
||||
user_id: str
|
||||
message: str
|
||||
|
||||
|
||||
class ZeppSyncRequest(BaseModel):
|
||||
"""Request to sync data from Zepp."""
|
||||
days: int = 30
|
||||
|
||||
|
||||
class ZeppSyncResponse(BaseModel):
|
||||
"""Response after syncing Zepp data."""
|
||||
status: str
|
||||
days_synced: int
|
||||
workouts_synced: int
|
||||
message: str
|
||||
|
||||
|
||||
class ZeppStatusResponse(BaseModel):
|
||||
"""Response with Zepp connection status."""
|
||||
connected: bool
|
||||
zepp_user_id: Optional[str] = None
|
||||
last_sync: Optional[datetime] = None
|
||||
|
||||
|
||||
# --- In-memory token storage (per user) ---
|
||||
# In production, this should be stored in the database
|
||||
_zepp_clients: dict[int, ZeppApiClient] = {}
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.post("/connect", response_model=ZeppConnectResponse)
|
||||
async def connect_zepp(
|
||||
request: ZeppConnectRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""
|
||||
Connect user's Zepp account.
|
||||
|
||||
Authenticates with Huami servers and stores the session for data syncing.
|
||||
"""
|
||||
try:
|
||||
client = ZeppApiClient(request.email, request.password)
|
||||
auth_result = client.authenticate()
|
||||
|
||||
# Store client for this user
|
||||
_zepp_clients[current_user.id] = client
|
||||
|
||||
logger.info(f"User {current_user.id} connected Zepp account: {auth_result['user_id']}")
|
||||
|
||||
return ZeppConnectResponse(
|
||||
status="success",
|
||||
user_id=auth_result["user_id"],
|
||||
message="Successfully connected to Zepp account"
|
||||
)
|
||||
|
||||
except ZeppApiError as e:
|
||||
logger.warning(f"Zepp connection failed for user {current_user.id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error connecting Zepp: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to connect to Zepp"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync", response_model=ZeppSyncResponse)
|
||||
async def sync_zepp(
|
||||
request: ZeppSyncRequest = ZeppSyncRequest(),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""
|
||||
Sync health data from connected Zepp account.
|
||||
|
||||
Fetches daily metrics (steps, sleep, heart rate, SpO2) and workout sessions.
|
||||
"""
|
||||
# Check if user has connected their Zepp account
|
||||
client = _zepp_clients.get(current_user.id)
|
||||
if not client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Zepp account not connected. Please connect first."
|
||||
)
|
||||
|
||||
try:
|
||||
# Fetch data from Zepp
|
||||
sync_result = sync_zepp_data(client, days=request.days)
|
||||
|
||||
# Save daily metrics to database
|
||||
metrics_saved = 0
|
||||
for entry in sync_result.get("daily_metrics", []):
|
||||
saved = _save_daily_metrics(db, current_user.id, entry)
|
||||
if saved:
|
||||
metrics_saved += 1
|
||||
|
||||
# Save workouts to database
|
||||
workouts_saved = 0
|
||||
for workout in sync_result.get("workouts", []):
|
||||
saved = _save_workout(db, current_user.id, workout)
|
||||
if saved:
|
||||
workouts_saved += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"User {current_user.id} synced {metrics_saved} days, {workouts_saved} workouts from Zepp"
|
||||
)
|
||||
|
||||
return ZeppSyncResponse(
|
||||
status="success",
|
||||
days_synced=metrics_saved,
|
||||
workouts_synced=workouts_saved,
|
||||
message=f"Synced {metrics_saved} days of data and {workouts_saved} workouts"
|
||||
)
|
||||
|
||||
except ZeppApiError as e:
|
||||
logger.error(f"Zepp sync failed: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Zepp API error: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during Zepp sync: {e}")
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to sync data from Zepp"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status", response_model=ZeppStatusResponse)
|
||||
async def get_zepp_status(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""Get the current Zepp connection status."""
|
||||
client = _zepp_clients.get(current_user.id)
|
||||
|
||||
if client and client.user_id:
|
||||
# Get last sync time from database
|
||||
last_metric = db.query(DailyHealthMetrics).filter_by(
|
||||
user_id=current_user.id
|
||||
).order_by(DailyHealthMetrics.date.desc()).first()
|
||||
|
||||
return ZeppStatusResponse(
|
||||
connected=True,
|
||||
zepp_user_id=client.user_id,
|
||||
last_sync=last_metric.date if last_metric else None
|
||||
)
|
||||
|
||||
return ZeppStatusResponse(connected=False)
|
||||
|
||||
|
||||
@router.delete("/disconnect")
|
||||
async def disconnect_zepp(
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""Disconnect Zepp account."""
|
||||
if current_user.id in _zepp_clients:
|
||||
del _zepp_clients[current_user.id]
|
||||
logger.info(f"User {current_user.id} disconnected Zepp account")
|
||||
|
||||
return {"status": "success", "message": "Zepp account disconnected"}
|
||||
|
||||
|
||||
# --- Helper Functions ---
|
||||
|
||||
def _save_daily_metrics(db: Session, user_id: int, entry: dict) -> bool:
|
||||
"""Save or update daily health metrics from Zepp data."""
|
||||
try:
|
||||
date_str = entry.get("date")
|
||||
if not date_str:
|
||||
return False
|
||||
|
||||
# Parse date
|
||||
if isinstance(date_str, str):
|
||||
d_date = datetime.strptime(date_str[:10], "%Y-%m-%d").date()
|
||||
else:
|
||||
d_date = date_str
|
||||
|
||||
# Find or create metric record
|
||||
metric = db.query(DailyHealthMetrics).filter_by(
|
||||
user_id=user_id, date=d_date
|
||||
).first()
|
||||
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=user_id, date=d_date)
|
||||
db.add(metric)
|
||||
|
||||
# Update activity metrics
|
||||
if entry.get("steps"):
|
||||
metric.step_count = entry["steps"]
|
||||
if entry.get("distance"):
|
||||
metric.distance_meters = entry["distance"]
|
||||
if entry.get("calories"):
|
||||
metric.calories_burned = entry["calories"]
|
||||
|
||||
# Update sleep metrics
|
||||
sleep = entry.get("sleep", {})
|
||||
if sleep:
|
||||
if sleep.get("total_minutes"):
|
||||
metric.sleep_duration_minutes = sleep["total_minutes"]
|
||||
if sleep.get("deep_sleep_minutes"):
|
||||
metric.deep_sleep_minutes = sleep["deep_sleep_minutes"]
|
||||
if sleep.get("light_sleep_minutes"):
|
||||
metric.light_sleep_minutes = sleep["light_sleep_minutes"]
|
||||
if sleep.get("rem_sleep_minutes"):
|
||||
metric.rem_sleep_minutes = sleep["rem_sleep_minutes"]
|
||||
if sleep.get("awake_minutes"):
|
||||
metric.awake_duration_minutes = sleep["awake_minutes"]
|
||||
|
||||
# Update heart rate metrics
|
||||
hr = entry.get("heart_rate", {})
|
||||
if hr:
|
||||
if hr.get("avg"):
|
||||
metric.avg_heart_rate = hr["avg"]
|
||||
if hr.get("min"):
|
||||
metric.min_heart_rate = hr["min"]
|
||||
if hr.get("max"):
|
||||
metric.max_heart_rate = hr["max"]
|
||||
if hr.get("resting"):
|
||||
metric.resting_heart_rate = hr["resting"]
|
||||
|
||||
# Update SpO2 metrics
|
||||
spo2 = entry.get("spo2", {})
|
||||
if spo2:
|
||||
if spo2.get("avg"):
|
||||
metric.avg_spo2 = spo2["avg"]
|
||||
if spo2.get("min"):
|
||||
metric.min_spo2 = spo2["min"]
|
||||
if spo2.get("max"):
|
||||
metric.max_spo2 = spo2["max"]
|
||||
|
||||
# Calculate simple PAI score
|
||||
pai = 0.0
|
||||
if metric.step_count:
|
||||
pai += metric.step_count / 1000
|
||||
if metric.calories_burned and metric.calories_burned > 0:
|
||||
pai += metric.calories_burned / 50
|
||||
if metric.avg_heart_rate and metric.avg_heart_rate > 100:
|
||||
pai += (metric.avg_heart_rate - 100) / 10
|
||||
metric.pai_score = round(min(pai, 100), 1)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving daily metrics: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _save_workout(db: Session, user_id: int, workout: dict) -> bool:
|
||||
"""Save or update workout session from Zepp data."""
|
||||
try:
|
||||
# Extract workout data
|
||||
start_time_str = workout.get("startTime") or workout.get("start_time")
|
||||
if not start_time_str:
|
||||
return False
|
||||
|
||||
# Parse start time
|
||||
if isinstance(start_time_str, str):
|
||||
# Handle various formats
|
||||
start_time_str = start_time_str.split("+")[0].split("-0")[0]
|
||||
try:
|
||||
start_time = datetime.strptime(start_time_str, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
start_time = datetime.strptime(start_time_str[:19], "%Y-%m-%dT%H:%M:%S")
|
||||
else:
|
||||
start_time = start_time_str
|
||||
|
||||
# Check if workout already exists
|
||||
existing = db.query(WorkoutSession).filter_by(
|
||||
user_id=user_id, start_time=start_time
|
||||
).first()
|
||||
|
||||
# Get workout type
|
||||
type_id = int(workout.get("type", 0))
|
||||
activity_name = ZEPP_ACTIVITY_TYPES.get(type_id, "Unknown")
|
||||
|
||||
# Duration
|
||||
duration = int(workout.get("sportTime(s)", workout.get("duration", 0)))
|
||||
|
||||
# End time
|
||||
end_time = start_time + timedelta(seconds=duration)
|
||||
|
||||
# Distance and calories
|
||||
distance = float(workout.get("distance(m)", workout.get("distance", 0)))
|
||||
calories = float(workout.get("calories(kcal)", workout.get("calories", 0)))
|
||||
|
||||
# Pace metrics (convert from /meter to min/km)
|
||||
avg_pace = float(workout.get("avgPace(/meter)", workout.get("avg_pace", 0)))
|
||||
min_pace = float(workout.get("minPace(/meter)", workout.get("min_pace", 0)))
|
||||
max_pace = float(workout.get("maxPace(/meter)", workout.get("max_pace", 0)))
|
||||
|
||||
if existing:
|
||||
# Update existing
|
||||
existing.activity_type = activity_name
|
||||
existing.activity_type_id = type_id
|
||||
existing.duration_seconds = duration
|
||||
existing.distance_meters = distance
|
||||
existing.calories = calories
|
||||
existing.avg_pace = avg_pace if avg_pace > 0 else None
|
||||
existing.min_pace = min_pace if min_pace > 0 else None
|
||||
existing.max_pace = max_pace if max_pace > 0 else None
|
||||
else:
|
||||
# Create new
|
||||
workout_session = 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,
|
||||
distance_meters=distance,
|
||||
calories=calories,
|
||||
avg_pace=avg_pace if avg_pace > 0 else None,
|
||||
min_pace=min_pace if min_pace > 0 else None,
|
||||
max_pace=max_pace if max_pace > 0 else None,
|
||||
)
|
||||
db.add(workout_session)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving workout: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,52 @@
|
||||
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
|
||||
|
||||
|
||||
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,61 @@
|
||||
"""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 = token_urlsafe(32)
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# 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: "google_fit", "health_connect", "dual" (both for comparison)
|
||||
HEALTH_PROVIDER: str = "google_fit"
|
||||
|
||||
@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,14 @@
|
||||
"""Integrations module for external APIs."""
|
||||
from backend.integrations.zepp_api import (
|
||||
ZeppApiClient,
|
||||
ZeppApiError,
|
||||
create_zepp_client,
|
||||
sync_zepp_data
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ZeppApiClient",
|
||||
"ZeppApiError",
|
||||
"create_zepp_client",
|
||||
"sync_zepp_data",
|
||||
]
|
||||
@@ -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,472 @@
|
||||
"""
|
||||
Zepp/Huami API Client
|
||||
|
||||
Provides authentication and data fetching from the Zepp/Huami cloud API
|
||||
for Amazfit and Mi Band devices.
|
||||
|
||||
Based on the Huami API technical documentation:
|
||||
- Authentication: account.huami.com
|
||||
- Data: api-mifit-de2.zepp.com (EU GDPR compliant)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from backend.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- Constants ---
|
||||
AUTH_URL = "https://account.huami.com/v2/client/login"
|
||||
API_BASE_EU = "https://api-mifit-de2.zepp.com"
|
||||
API_BASE_GLOBAL = "https://api-mifit.huami.com"
|
||||
|
||||
# App identification (mimics official Zepp app)
|
||||
APP_NAME = "com.huami.watch.hmwatchmanager"
|
||||
CLIENT_ID = "HuaMi"
|
||||
APP_VERSION = "6.9.3"
|
||||
|
||||
|
||||
class ZeppApiError(Exception):
|
||||
"""Custom exception for Zepp API errors."""
|
||||
pass
|
||||
|
||||
|
||||
class ZeppApiClient:
|
||||
"""
|
||||
Client for interacting with the Zepp/Huami API.
|
||||
|
||||
Handles authentication, token management, and data fetching.
|
||||
"""
|
||||
|
||||
def __init__(self, email: str = None, password: str = None):
|
||||
self.email = email
|
||||
self.password = password
|
||||
self.app_token: Optional[str] = None
|
||||
self.login_token: Optional[str] = None
|
||||
self.user_id: Optional[str] = None
|
||||
self.token_expiry: Optional[datetime.datetime] = None
|
||||
self.api_base = API_BASE_EU # Default to EU for GDPR
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update({
|
||||
"User-Agent": f"Zepp/{APP_VERSION} (Android)",
|
||||
"Accept": "application/json",
|
||||
})
|
||||
|
||||
def authenticate(self, email: str = None, password: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Authenticate with the Huami/Zepp API.
|
||||
|
||||
Args:
|
||||
email: Zepp account email
|
||||
password: Zepp account password
|
||||
|
||||
Returns:
|
||||
Dict containing user_id, app_token, and other session info
|
||||
"""
|
||||
email = email or self.email
|
||||
password = password or self.password
|
||||
|
||||
if not email or not password:
|
||||
raise ZeppApiError("Email and password are required")
|
||||
|
||||
# Generate unique device ID
|
||||
device_id = uuid.uuid4().hex[:16].upper()
|
||||
|
||||
# Prepare authentication request
|
||||
auth_data = {
|
||||
"grant_type": "access_token",
|
||||
"client_id": CLIENT_ID,
|
||||
"app_name": APP_NAME,
|
||||
"app_version": APP_VERSION,
|
||||
"third_name": "huami",
|
||||
"device_id": device_id,
|
||||
"device_model": "android_phone",
|
||||
"allow_registration": "false",
|
||||
"dn": "api-user.huami.com,api-mifit-de2.zepp.com,app-analytics.huami.com",
|
||||
"country_code": "DE",
|
||||
"lang": "en",
|
||||
"state": "REDIRECTION",
|
||||
"redirect_uri": "https://s3-us-west-2.amazonws.com/hm-registration/successs498.html",
|
||||
}
|
||||
|
||||
# Add credentials
|
||||
auth_data["email"] = email
|
||||
auth_data["password"] = password
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
|
||||
try:
|
||||
response = self._session.post(
|
||||
AUTH_URL,
|
||||
data=auth_data,
|
||||
headers=headers,
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
|
||||
if "error_code" in result:
|
||||
error_msg = result.get("message", "Authentication failed")
|
||||
raise ZeppApiError(f"Auth error: {error_msg}")
|
||||
|
||||
# Extract tokens
|
||||
token_info = result.get("token_info", {})
|
||||
self.app_token = token_info.get("app_token")
|
||||
self.login_token = token_info.get("login_token")
|
||||
self.user_id = token_info.get("user_id")
|
||||
|
||||
if not self.app_token or not self.user_id:
|
||||
raise ZeppApiError("Invalid authentication response: missing tokens")
|
||||
|
||||
# Store credentials for refresh
|
||||
self.email = email
|
||||
self.password = password
|
||||
|
||||
# Set token expiry (typically 30 days, be conservative with 7)
|
||||
self.token_expiry = datetime.datetime.now() + datetime.timedelta(days=7)
|
||||
|
||||
logger.info(f"Successfully authenticated Zepp user: {self.user_id}")
|
||||
|
||||
return {
|
||||
"user_id": self.user_id,
|
||||
"app_token": self.app_token,
|
||||
"login_token": self.login_token,
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Zepp authentication request failed: {e}")
|
||||
raise ZeppApiError(f"Network error during authentication: {e}")
|
||||
|
||||
def _ensure_authenticated(self):
|
||||
"""Ensure we have valid tokens, re-authenticate if needed."""
|
||||
if not self.app_token or not self.user_id:
|
||||
if self.email and self.password:
|
||||
self.authenticate()
|
||||
else:
|
||||
raise ZeppApiError("Not authenticated. Call authenticate() first.")
|
||||
|
||||
# Check token expiry
|
||||
if self.token_expiry and datetime.datetime.now() > self.token_expiry:
|
||||
logger.info("Token expired, re-authenticating...")
|
||||
self.authenticate()
|
||||
|
||||
def _make_request(self, endpoint: str, params: Dict = None, method: str = "GET") -> Dict:
|
||||
"""
|
||||
Make an authenticated request to the Zepp API.
|
||||
"""
|
||||
self._ensure_authenticated()
|
||||
|
||||
url = f"{self.api_base}{endpoint}"
|
||||
|
||||
headers = {
|
||||
"apptoken": self.app_token,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
if method == "GET":
|
||||
response = self._session.get(url, params=params, headers=headers, timeout=30)
|
||||
else:
|
||||
response = self._session.post(url, data=params, headers=headers, timeout=30)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Zepp API request failed: {e}")
|
||||
raise ZeppApiError(f"API request failed: {e}")
|
||||
|
||||
def fetch_band_data(
|
||||
self,
|
||||
from_date: datetime.date,
|
||||
to_date: datetime.date,
|
||||
query_type: str = "summary"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch daily health data (steps, sleep, heart rate).
|
||||
|
||||
Args:
|
||||
from_date: Start date
|
||||
to_date: End date
|
||||
query_type: "summary" for daily totals, "detail" for minute-by-minute
|
||||
|
||||
Returns:
|
||||
Dict containing decoded health data
|
||||
"""
|
||||
params = {
|
||||
"query_type": query_type,
|
||||
"from_date": from_date.strftime("%Y-%m-%d"),
|
||||
"to_date": to_date.strftime("%Y-%m-%d"),
|
||||
"userid": self.user_id,
|
||||
"device_type": "android_phone",
|
||||
}
|
||||
|
||||
response = self._make_request("/v1/data/band_data.json", params)
|
||||
|
||||
if response.get("code") != 1:
|
||||
raise ZeppApiError(f"band_data request failed: {response.get('message')}")
|
||||
|
||||
# Decode the Base64 summary data
|
||||
data_list = response.get("data", [])
|
||||
decoded_data = []
|
||||
|
||||
for entry in data_list:
|
||||
decoded_entry = self._decode_band_data_entry(entry)
|
||||
if decoded_entry:
|
||||
decoded_data.append(decoded_entry)
|
||||
|
||||
return {"data": decoded_data, "raw_response": response}
|
||||
|
||||
def _decode_band_data_entry(self, entry: Dict) -> Optional[Dict]:
|
||||
"""
|
||||
Decode a single band_data entry with Base64 encoded fields.
|
||||
"""
|
||||
try:
|
||||
date_str = entry.get("date_time", entry.get("date"))
|
||||
if not date_str:
|
||||
return None
|
||||
|
||||
result = {
|
||||
"date": date_str,
|
||||
"summary": {},
|
||||
}
|
||||
|
||||
# Decode summary field (Base64 JSON)
|
||||
summary_b64 = entry.get("summary")
|
||||
if summary_b64:
|
||||
try:
|
||||
summary_json = base64.b64decode(summary_b64).decode("utf-8")
|
||||
result["summary"] = json.loads(summary_json)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode summary: {e}")
|
||||
|
||||
# Extract steps data
|
||||
stp = result["summary"].get("stp", {})
|
||||
result["steps"] = stp.get("ttl", 0)
|
||||
result["distance"] = stp.get("dis", 0)
|
||||
result["calories"] = stp.get("cal", 0)
|
||||
|
||||
# Extract sleep data
|
||||
slp = result["summary"].get("slp", {})
|
||||
if slp:
|
||||
result["sleep"] = {
|
||||
"start_time": slp.get("st"),
|
||||
"end_time": slp.get("ed"),
|
||||
"deep_sleep_minutes": slp.get("dp", 0),
|
||||
"light_sleep_minutes": slp.get("lt", 0),
|
||||
"rem_sleep_minutes": slp.get("rm", 0),
|
||||
"awake_minutes": slp.get("wk", 0),
|
||||
}
|
||||
# Calculate total
|
||||
result["sleep"]["total_minutes"] = (
|
||||
result["sleep"]["deep_sleep_minutes"] +
|
||||
result["sleep"]["light_sleep_minutes"] +
|
||||
result["sleep"]["rem_sleep_minutes"]
|
||||
)
|
||||
|
||||
# Decode heart rate blob if present
|
||||
hr_blob = entry.get("data_hr")
|
||||
if hr_blob:
|
||||
hr_values = self._decode_heart_rate_blob(hr_blob)
|
||||
if hr_values:
|
||||
valid_hr = [v for v in hr_values if 30 < v < 220]
|
||||
if valid_hr:
|
||||
result["heart_rate"] = {
|
||||
"avg": sum(valid_hr) // len(valid_hr),
|
||||
"min": min(valid_hr),
|
||||
"max": max(valid_hr),
|
||||
"resting": min(valid_hr), # Approximation
|
||||
}
|
||||
|
||||
# Decode SpO2 if present
|
||||
spo2_blob = entry.get("data_spo2")
|
||||
if spo2_blob:
|
||||
spo2_values = self._decode_spo2_blob(spo2_blob)
|
||||
if spo2_values:
|
||||
valid_spo2 = [v for v in spo2_values if 70 < v <= 100]
|
||||
if valid_spo2:
|
||||
result["spo2"] = {
|
||||
"avg": sum(valid_spo2) / len(valid_spo2),
|
||||
"min": min(valid_spo2),
|
||||
"max": max(valid_spo2),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error decoding band_data entry: {e}")
|
||||
return None
|
||||
|
||||
def _decode_heart_rate_blob(self, b64_data: str) -> List[int]:
|
||||
"""
|
||||
Decode Base64-encoded heart rate binary data.
|
||||
Format: sequence of 1-byte or 2-byte integers.
|
||||
"""
|
||||
try:
|
||||
raw = base64.b64decode(b64_data)
|
||||
# Try single-byte format first (most common)
|
||||
values = list(raw)
|
||||
# Filter out invalid values (0, 255 are typically "no reading")
|
||||
return [v for v in values if v > 0 and v < 255]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode HR blob: {e}")
|
||||
return []
|
||||
|
||||
def _decode_spo2_blob(self, b64_data: str) -> List[float]:
|
||||
"""
|
||||
Decode Base64-encoded SpO2 binary data.
|
||||
"""
|
||||
try:
|
||||
raw = base64.b64decode(b64_data)
|
||||
# SpO2 is typically stored as single bytes (percentage)
|
||||
values = list(raw)
|
||||
return [float(v) for v in values if v > 0]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode SpO2 blob: {e}")
|
||||
return []
|
||||
|
||||
def fetch_workout_history(self, days: int = 30) -> List[Dict]:
|
||||
"""
|
||||
Fetch workout/sport session history.
|
||||
|
||||
Args:
|
||||
days: Number of days of history to fetch
|
||||
|
||||
Returns:
|
||||
List of workout sessions
|
||||
"""
|
||||
from_date = datetime.date.today() - datetime.timedelta(days=days)
|
||||
|
||||
params = {
|
||||
"from_date": from_date.strftime("%Y-%m-%d"),
|
||||
"to_date": datetime.date.today().strftime("%Y-%m-%d"),
|
||||
"userid": self.user_id,
|
||||
}
|
||||
|
||||
try:
|
||||
response = self._make_request("/v1/sport/run/history.json", params)
|
||||
|
||||
if response.get("code") != 1:
|
||||
logger.warning(f"Workout history request failed: {response}")
|
||||
return []
|
||||
|
||||
return response.get("data", {}).get("summary", [])
|
||||
|
||||
except ZeppApiError as e:
|
||||
logger.warning(f"Failed to fetch workout history: {e}")
|
||||
return []
|
||||
|
||||
def fetch_workout_detail(self, track_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Fetch detailed workout data including GPS track.
|
||||
|
||||
Args:
|
||||
track_id: The workout track ID
|
||||
|
||||
Returns:
|
||||
Dict with workout details and GPS data
|
||||
"""
|
||||
params = {
|
||||
"trackid": track_id,
|
||||
"source": "run.mifit.huami.com",
|
||||
}
|
||||
|
||||
try:
|
||||
response = self._make_request("/v1/sport/run/detail.json", params)
|
||||
|
||||
if response.get("code") != 1:
|
||||
return None
|
||||
|
||||
data = response.get("data", {})
|
||||
|
||||
# Parse GPS coordinates if present
|
||||
gps_str = data.get("longitude_latitude", "")
|
||||
if gps_str:
|
||||
data["gps_track"] = self._parse_gps_string(gps_str)
|
||||
|
||||
return data
|
||||
|
||||
except ZeppApiError as e:
|
||||
logger.warning(f"Failed to fetch workout detail {track_id}: {e}")
|
||||
return None
|
||||
|
||||
def _parse_gps_string(self, gps_str: str) -> List[Tuple[float, float]]:
|
||||
"""
|
||||
Parse Zepp's proprietary GPS format.
|
||||
Format: lat,lon;lat,lon;lat,lon...
|
||||
"""
|
||||
points = []
|
||||
try:
|
||||
for point_str in gps_str.split(";"):
|
||||
if "," in point_str:
|
||||
parts = point_str.split(",")
|
||||
if len(parts) >= 2:
|
||||
lat = float(parts[0])
|
||||
lon = float(parts[1])
|
||||
points.append((lat, lon))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse GPS string: {e}")
|
||||
return points
|
||||
|
||||
|
||||
# --- Convenience Functions ---
|
||||
|
||||
def create_zepp_client(email: str, password: str) -> ZeppApiClient:
|
||||
"""Create and authenticate a Zepp API client."""
|
||||
client = ZeppApiClient(email, password)
|
||||
client.authenticate()
|
||||
return client
|
||||
|
||||
|
||||
def sync_zepp_data(
|
||||
client: ZeppApiClient,
|
||||
days: int = 30
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Sync all available data from Zepp.
|
||||
|
||||
Args:
|
||||
client: Authenticated ZeppApiClient
|
||||
days: Number of days to sync
|
||||
|
||||
Returns:
|
||||
Dict with synced data counts
|
||||
"""
|
||||
from_date = datetime.date.today() - datetime.timedelta(days=days)
|
||||
to_date = datetime.date.today()
|
||||
|
||||
results = {
|
||||
"daily_metrics": [],
|
||||
"workouts": [],
|
||||
"days_synced": 0,
|
||||
"workouts_synced": 0,
|
||||
}
|
||||
|
||||
# Fetch band data (daily metrics)
|
||||
try:
|
||||
band_data = client.fetch_band_data(from_date, to_date)
|
||||
results["daily_metrics"] = band_data.get("data", [])
|
||||
results["days_synced"] = len(results["daily_metrics"])
|
||||
except ZeppApiError as e:
|
||||
logger.error(f"Failed to fetch band data: {e}")
|
||||
|
||||
# Fetch workouts
|
||||
try:
|
||||
workouts = client.fetch_workout_history(days)
|
||||
results["workouts"] = workouts
|
||||
results["workouts_synced"] = len(workouts)
|
||||
except ZeppApiError as e:
|
||||
logger.error(f"Failed to fetch workouts: {e}")
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,69 @@
|
||||
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,
|
||||
zepp as zepp_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
|
||||
from backend.models import User, Challenge, Tracking, DailyHealthMetrics, WorkoutSession
|
||||
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(zepp_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,8 @@
|
||||
"""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
|
||||
|
||||
__all__ = ["User", "Challenge", "Tracking", "DailyHealthMetrics", "WorkoutSession"]
|
||||
@@ -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,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="google_fit") # "google_fit", "health_connect", "manual"
|
||||
|
||||
user = relationship("User", back_populates="health_metrics")
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
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)
|
||||
|
||||
# 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,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="google_fit") # "google_fit", "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,8 @@
|
||||
"""Token Pydantic schema."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Schema for JWT token response."""
|
||||
access_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,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"
|
||||
}
|
||||
Reference in New Issue
Block a user