Initial commit: HabitForge - habit tracking application with FastAPI backend, React frontend, and Zepp integration

This commit is contained in:
2026-07-31 08:58:53 -04:00
commit c7fce339c2
124 changed files with 17558 additions and 0 deletions
+26
View File
@@ -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
+23
View File
@@ -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"
]
+1
View File
@@ -0,0 +1 @@
"""API module for HabitForge."""
+1
View File
@@ -0,0 +1 @@
"""API v1 module for HabitForge."""
+63
View File
@@ -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
+118
View File
@@ -0,0 +1,118 @@
"""Challenge endpoints for API v1."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List, Optional
import datetime
from backend.models.challenge import Challenge
from backend.models.tracking import Tracking
from backend.models.user import User
from backend import auth
from backend.schemas.challenge import ChallengeCreate, ChallengeResponse
from backend.schemas.tracking import TrackingCreate, TrackingResponse
from backend.database import get_db
router = APIRouter(prefix="/api/challenges", tags=["challenges"])
@router.post("", response_model=ChallengeResponse, status_code=status.HTTP_201_CREATED)
def create_challenge(
challenge: ChallengeCreate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Create a new challenge."""
# Calculate next display_order for THIS user
max_order = db.query(func.max(Challenge.display_order)).filter(
Challenge.user_id == current_user.id
).scalar()
new_order = (max_order if max_order is not None else -1) + 1
db_challenge = Challenge(**challenge.dict(), display_order=new_order, user_id=current_user.id)
db.add(db_challenge)
db.commit()
db.refresh(db_challenge)
return db_challenge
@router.get("", response_model=List[ChallengeResponse])
def read_challenges(
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get all challenges for the current user."""
challenges = db.query(Challenge).filter(
Challenge.user_id == current_user.id
).order_by(Challenge.display_order.asc()).all()
return challenges
@router.put("/reorder", status_code=status.HTTP_204_NO_CONTENT)
def reorder_challenges(
ordered_ids: List[int],
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Reorder challenges."""
# Sanity check: ensure these challenges belong to user
for index, challenge_id in enumerate(ordered_ids):
db.query(Challenge).filter(
Challenge.id == challenge_id,
Challenge.user_id == current_user.id
).update({"display_order": index})
db.commit()
return None
@router.delete("/{challenge_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_challenge(
challenge_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Delete a challenge."""
db_challenge = db.query(Challenge).filter(
Challenge.id == challenge_id,
Challenge.user_id == current_user.id
).first()
if not db_challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
db.delete(db_challenge)
db.commit()
return None
@router.post("/{challenge_id}/tracking", response_model=TrackingResponse)
def log_tracking(
challenge_id: int,
tracking: TrackingCreate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Log tracking for a challenge."""
# Verify challenge belongs to user
challenge = db.query(Challenge).filter(
Challenge.id == challenge_id,
Challenge.user_id == current_user.id
).first()
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
# Check if tracking exists for this date and challenge
db_tracking = db.query(Tracking).filter(
Tracking.challenge_id == challenge_id,
Tracking.date == tracking.date
).first()
if db_tracking:
db_tracking.reps = tracking.reps
db_tracking.completed = tracking.completed
db_tracking.notes = tracking.notes
else:
db_tracking = Tracking(**tracking.dict())
db.add(db_tracking)
db.commit()
db.refresh(db_tracking)
return db_tracking
+215
View File
@@ -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
]
}
+367
View File
@@ -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
+52
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Core module for configuration and security."""
+61
View File
@@ -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()
+29
View File
@@ -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()
+14
View File
@@ -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",
]
+434
View File
@@ -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()
+472
View File
@@ -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
+69
View File
@@ -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")
+179
View File
@@ -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
+8
View File
@@ -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"]
+30
View File
@@ -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")
+37
View File
@@ -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")
+177
View File
@@ -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",
}
+18
View File
@@ -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")
+17
View File
@@ -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")
+48
View File
@@ -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}")
+1
View File
@@ -0,0 +1 @@
"""Repositories module for data access layer."""
+49
View File
@@ -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()
+17
View File
@@ -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()
+16
View File
@@ -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"
]
+38
View File
@@ -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
+34
View File
@@ -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
+8
View File
@@ -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
+26
View File
@@ -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
+17
View File
@@ -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
+36
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Services module for business logic."""
+52
View File
@@ -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
+86
View File
@@ -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()
+26
View File
@@ -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"
}