Initial commit: HabitForge - habit tracking application with FastAPI backend, React frontend, and Zepp integration
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user