feat: Update spec, fix bugs, improve UI/UX, and clean up code
This commit is contained in:
@@ -2,55 +2,47 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from contextlib import asynccontextmanager
|
||||
from src.services.postgresql_manager import PostgreSQLManager
|
||||
from src.utils.logging_config import setup_logging
|
||||
from alembic.config import Config
|
||||
from alembic import command
|
||||
import os
|
||||
import logging
|
||||
|
||||
# Create application lifespan to handle startup/shutdown
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
# Run database migrations
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
database_url = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/fitbit_garmin_sync")
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
setup_logging()
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("--- Application Starting Up ---")
|
||||
|
||||
# Initialize database tables
|
||||
db_manager = PostgreSQLManager(database_url=database_url)
|
||||
db_manager.init_db()
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if database_url:
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
|
||||
try:
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
logger.info("Database migrations checked/applied.")
|
||||
except Exception as e:
|
||||
logger.error(f"Error running database migrations: {e}")
|
||||
else:
|
||||
logger.warning("DATABASE_URL not set, skipping migrations.")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
# Add any cleanup code here if needed
|
||||
logger.info("--- Application Shutting Down ---")
|
||||
|
||||
# Create FastAPI app with lifespan
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
# Mount static files
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
# Initialize templates
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# Include API routes
|
||||
from src.api.status import router as status_router
|
||||
from src.api.sync import router as sync_router
|
||||
from src.api.setup import router as setup_router
|
||||
from src.api.logs import router as logs_router
|
||||
from src.api.metrics import router as metrics_router
|
||||
from src.api.activities import router as activities_router
|
||||
from src.api import status, sync, setup, logs, metrics, activities
|
||||
|
||||
app.include_router(status_router, prefix="/api")
|
||||
app.include_router(sync_router, prefix="/api")
|
||||
app.include_router(setup_router, prefix="/api")
|
||||
app.include_router(logs_router, prefix="/api")
|
||||
app.include_router(metrics_router, prefix="/api")
|
||||
app.include_router(activities_router, prefix="/api")
|
||||
|
||||
from fastapi import Request
|
||||
app.include_router(status.router, prefix="/api")
|
||||
app.include_router(sync.router, prefix="/api")
|
||||
app.include_router(setup.router, prefix="/api")
|
||||
app.include_router(logs.router, prefix="/api")
|
||||
app.include_router(metrics.router, prefix="/api")
|
||||
app.include_router(activities.router, prefix="/api")
|
||||
|
||||
@app.get("/")
|
||||
async def read_root(request: Request):
|
||||
@@ -58,4 +50,4 @@ async def read_root(request: Request):
|
||||
|
||||
@app.get("/setup")
|
||||
async def setup_page(request: Request):
|
||||
return templates.TemplateResponse("setup.html", {"request": request})
|
||||
return templates.TemplateResponse("setup.html", {"request": request})
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
from fastapi import APIRouter, Query, Response
|
||||
from fastapi import APIRouter, Query, Response, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional, Dict, Any
|
||||
from sqlalchemy import func
|
||||
from ..models.activity import Activity
|
||||
import logging
|
||||
from ..services.postgresql_manager import PostgreSQLManager
|
||||
from sqlalchemy.orm import Session
|
||||
from ..utils.config import config
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_db():
|
||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
||||
with db_manager.get_db_session() as session:
|
||||
yield session
|
||||
|
||||
class ActivityResponse(BaseModel):
|
||||
id: Optional[int] = None
|
||||
garmin_activity_id: Optional[str] = None
|
||||
@@ -19,26 +32,143 @@ class ActivityResponse(BaseModel):
|
||||
@router.get("/activities/list", response_model=List[ActivityResponse])
|
||||
async def list_activities(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0)
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# This would return metadata for all downloaded/available activities
|
||||
# Implementation will connect with the services layer
|
||||
return []
|
||||
"""
|
||||
Return metadata for all downloaded/available activities.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Listing activities with limit={limit}, offset={offset}")
|
||||
|
||||
# Query the database for activities
|
||||
activities = db.query(Activity).offset(offset).limit(limit).all()
|
||||
|
||||
# Convert SQLAlchemy objects to Pydantic models
|
||||
activity_responses = []
|
||||
for activity in activities:
|
||||
activity_responses.append(
|
||||
ActivityResponse(
|
||||
id=activity.id,
|
||||
garmin_activity_id=activity.garmin_activity_id,
|
||||
activity_name=activity.activity_name,
|
||||
activity_type=activity.activity_type,
|
||||
start_time=activity.start_time.isoformat() if activity.start_time else None,
|
||||
duration=activity.duration,
|
||||
file_type=activity.file_type,
|
||||
download_status=activity.download_status,
|
||||
downloaded_at=activity.downloaded_at.isoformat() if activity.downloaded_at else None
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f"Returning {len(activity_responses)} activities")
|
||||
return activity_responses
|
||||
except Exception as e:
|
||||
logger.error(f"Error in list_activities: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Error listing activities: {str(e)}")
|
||||
|
||||
@router.get("/activities/query", response_model=List[ActivityResponse])
|
||||
async def query_activities(
|
||||
activity_type: Optional[str] = Query(None),
|
||||
start_date: Optional[str] = Query(None),
|
||||
end_date: Optional[str] = Query(None),
|
||||
download_status: Optional[str] = Query(None)
|
||||
download_status: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# This would allow advanced filtering of activities
|
||||
# Implementation will connect with the services layer
|
||||
return []
|
||||
"""
|
||||
Allow advanced filtering of activities.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Querying activities - type: {activity_type}, start: {start_date}, end: {end_date}, status: {download_status}")
|
||||
|
||||
# Start building the query
|
||||
query = db.query(Activity)
|
||||
|
||||
# Apply filters based on parameters
|
||||
if activity_type:
|
||||
query = query.filter(Activity.activity_type == activity_type)
|
||||
|
||||
if start_date:
|
||||
from datetime import datetime
|
||||
start_dt = datetime.fromisoformat(start_date)
|
||||
query = query.filter(Activity.start_time >= start_dt)
|
||||
|
||||
if end_date:
|
||||
from datetime import datetime
|
||||
end_dt = datetime.fromisoformat(end_date)
|
||||
query = query.filter(Activity.start_time <= end_dt)
|
||||
|
||||
if download_status:
|
||||
query = query.filter(Activity.download_status == download_status)
|
||||
|
||||
# Execute the query
|
||||
activities = query.all()
|
||||
|
||||
# Convert SQLAlchemy objects to Pydantic models
|
||||
activity_responses = []
|
||||
for activity in activities:
|
||||
activity_responses.append(
|
||||
ActivityResponse(
|
||||
id=activity.id,
|
||||
garmin_activity_id=activity.garmin_activity_id,
|
||||
activity_name=activity.activity_name,
|
||||
activity_type=activity.activity_type,
|
||||
start_time=activity.start_time.isoformat() if activity.start_time else None,
|
||||
duration=activity.duration,
|
||||
file_type=activity.file_type,
|
||||
download_status=activity.download_status,
|
||||
downloaded_at=activity.downloaded_at.isoformat() if activity.downloaded_at else None
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f"Returning {len(activity_responses)} filtered activities")
|
||||
return activity_responses
|
||||
except Exception as e:
|
||||
logger.error(f"Error in query_activities: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Error querying activities: {str(e)}")
|
||||
|
||||
@router.get("/activities/download/{activity_id}")
|
||||
async def download_activity(activity_id: str):
|
||||
# This would serve the stored activity file from the database
|
||||
# Implementation will connect with the services layer
|
||||
# It should return the file content with appropriate content-type
|
||||
return Response(content=b"sample_content", media_type="application/octet-stream", headers={"Content-Disposition": f"attachment; filename=activity_{activity_id}.tcx"})
|
||||
async def download_activity(activity_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Serve the stored activity file from the database.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Downloading activity with ID: {activity_id}")
|
||||
|
||||
# Find the activity in the database
|
||||
activity = db.query(Activity).filter(Activity.garmin_activity_id == activity_id).first()
|
||||
|
||||
if not activity:
|
||||
raise HTTPException(status_code=404, detail=f"Activity with ID {activity_id} not found")
|
||||
|
||||
if not activity.file_content:
|
||||
raise HTTPException(status_code=404, detail=f"No file content available for activity {activity_id}")
|
||||
|
||||
if activity.download_status != 'downloaded':
|
||||
raise HTTPException(status_code=400, detail=f"File for activity {activity_id} is not ready for download (status: {activity.download_status})")
|
||||
|
||||
# Determine the appropriate content type based on the file type
|
||||
content_type_map = {
|
||||
'tcx': 'application/vnd.garmin.tcx+xml',
|
||||
'gpx': 'application/gpx+xml',
|
||||
'fit': 'application/octet-stream' # FIT files are binary
|
||||
}
|
||||
|
||||
content_type = content_type_map.get(activity.file_type, 'application/octet-stream')
|
||||
filename = f"activity_{activity_id}.{activity.file_type}"
|
||||
|
||||
logger.info(f"Returning file for activity {activity_id} with content type {content_type}")
|
||||
return Response(
|
||||
content=activity.file_content,
|
||||
media_type=content_type,
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}",
|
||||
"Content-Length": str(len(activity.file_content))
|
||||
}
|
||||
)
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions as-is
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error in download_activity for ID {activity_id}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Error downloading activity: {str(e)}")
|
||||
@@ -1,9 +1,22 @@
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi import APIRouter, Query, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional, Dict, Any
|
||||
from sqlalchemy import func
|
||||
from ..models.health_metric import HealthMetric
|
||||
import logging
|
||||
from ..services.postgresql_manager import PostgreSQLManager
|
||||
from sqlalchemy.orm import Session
|
||||
from ..utils.config import config
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_db():
|
||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
||||
with db_manager.get_db_session() as session:
|
||||
yield session
|
||||
|
||||
class HealthMetricResponse(BaseModel):
|
||||
id: int
|
||||
metric_type: str
|
||||
@@ -28,71 +41,188 @@ class HealthDataSummary(BaseModel):
|
||||
total_sleep_hours: Optional[float] = 0.0
|
||||
avg_calories: Optional[float] = 0.0
|
||||
|
||||
class ActivityResponse(BaseModel):
|
||||
id: Optional[int] = None
|
||||
garmin_activity_id: Optional[str] = None
|
||||
activity_name: Optional[str] = None
|
||||
activity_type: Optional[str] = None
|
||||
start_time: Optional[str] = None
|
||||
duration: Optional[int] = None
|
||||
file_path: Optional[str] = None
|
||||
file_type: Optional[str] = None
|
||||
download_status: Optional[str] = None
|
||||
downloaded_at: Optional[str] = None
|
||||
|
||||
@router.get("/metrics/list", response_model=MetricsListResponse)
|
||||
async def list_available_metrics():
|
||||
# This would return available metric types and date ranges
|
||||
# Implementation will connect with the services layer
|
||||
return {
|
||||
"metric_types": ["steps", "heart_rate", "sleep", "calories"],
|
||||
"date_range": {
|
||||
"start_date": "2023-01-01",
|
||||
"end_date": "2023-12-31"
|
||||
async def list_available_metrics(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Return available metric types and date ranges.
|
||||
"""
|
||||
try:
|
||||
logger.info("Listing available metrics")
|
||||
|
||||
# Query for distinct metric types from the database
|
||||
metric_types_result = db.query(HealthMetric.metric_type).distinct().all()
|
||||
metric_types = [row[0] for row in metric_types_result if row[0] is not None]
|
||||
|
||||
# Find the date range of available metrics
|
||||
min_date_result = db.query(func.min(HealthMetric.date)).scalar()
|
||||
max_date_result = db.query(func.max(HealthMetric.date)).scalar()
|
||||
|
||||
start_date = min_date_result.isoformat() if min_date_result else None
|
||||
end_date = max_date_result.isoformat() if max_date_result else None
|
||||
|
||||
response = {
|
||||
"metric_types": metric_types,
|
||||
"date_range": {
|
||||
"start_date": start_date,
|
||||
"end_date": end_date
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(f"Returning {len(metric_types)} metric types with date range {start_date} to {end_date}")
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"Error in list_available_metrics: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Error listing metrics: {str(e)}")
|
||||
|
||||
@router.get("/metrics/query", response_model=List[HealthMetricResponse])
|
||||
async def query_metrics(
|
||||
metric_type: Optional[str] = Query(None),
|
||||
start_date: Optional[str] = Query(None),
|
||||
end_date: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=1000)
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# This would query health metrics with filters
|
||||
# Implementation will connect with the services layer
|
||||
return []
|
||||
"""
|
||||
Query health metrics with filters.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Querying metrics - type: {metric_type}, start: {start_date}, end: {end_date}, limit: {limit}")
|
||||
|
||||
# Start building the query
|
||||
query = db.query(HealthMetric)
|
||||
|
||||
# Apply filters based on parameters
|
||||
if metric_type:
|
||||
query = query.filter(HealthMetric.metric_type == metric_type)
|
||||
|
||||
if start_date:
|
||||
from datetime import datetime
|
||||
start_dt = datetime.fromisoformat(start_date)
|
||||
query = query.filter(HealthMetric.date >= start_dt.date())
|
||||
|
||||
if end_date:
|
||||
from datetime import datetime
|
||||
end_dt = datetime.fromisoformat(end_date)
|
||||
query = query.filter(HealthMetric.date <= end_dt.date())
|
||||
|
||||
# Apply limit
|
||||
query = query.limit(limit)
|
||||
|
||||
# Execute the query
|
||||
health_metrics = query.all()
|
||||
|
||||
# Convert SQLAlchemy objects to Pydantic models
|
||||
metric_responses = []
|
||||
for metric in health_metrics:
|
||||
metric_responses.append(
|
||||
HealthMetricResponse(
|
||||
id=metric.id,
|
||||
metric_type=metric.metric_type,
|
||||
metric_value=metric.metric_value,
|
||||
unit=metric.unit,
|
||||
timestamp=metric.timestamp.isoformat() if metric.timestamp else "",
|
||||
date=metric.date.isoformat() if metric.date else "",
|
||||
source=metric.source,
|
||||
detailed_data=metric.detailed_data
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f"Returning {len(metric_responses)} health metrics")
|
||||
return metric_responses
|
||||
except Exception as e:
|
||||
logger.error(f"Error in query_metrics: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Error querying metrics: {str(e)}")
|
||||
|
||||
@router.get("/health-data/summary", response_model=HealthDataSummary)
|
||||
async def get_health_summary(
|
||||
start_date: Optional[str] = Query(None),
|
||||
end_date: Optional[str] = Query(None)
|
||||
):
|
||||
# This would return aggregated health statistics
|
||||
# Implementation will connect with the services layer
|
||||
return {
|
||||
"total_steps": 123456,
|
||||
"avg_heart_rate": 72.5,
|
||||
"total_sleep_hours": 210.5,
|
||||
"avg_calories": 2345.6
|
||||
}
|
||||
|
||||
@router.get("/activities/list", response_model=List[ActivityResponse])
|
||||
async def list_activities(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0)
|
||||
):
|
||||
# This would return metadata for all downloaded/available activities
|
||||
# Implementation will connect with the services layer
|
||||
return []
|
||||
|
||||
@router.get("/activities/query", response_model=List[ActivityResponse])
|
||||
async def query_activities(
|
||||
activity_type: Optional[str] = Query(None),
|
||||
start_date: Optional[str] = Query(None),
|
||||
end_date: Optional[str] = Query(None),
|
||||
download_status: Optional[str] = Query(None)
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# This would allow advanced filtering of activities
|
||||
# Implementation will connect with the services layer
|
||||
return []
|
||||
"""
|
||||
Return aggregated health statistics.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Getting health summary - start: {start_date}, end: {end_date}")
|
||||
|
||||
# Start building the query for steps
|
||||
steps_query = db.query(func.sum(HealthMetric.metric_value)).filter(HealthMetric.metric_type == 'steps')
|
||||
|
||||
# Apply date filters to steps query
|
||||
if start_date:
|
||||
from datetime import datetime
|
||||
start_dt = datetime.fromisoformat(start_date)
|
||||
steps_query = steps_query.filter(HealthMetric.date >= start_dt.date())
|
||||
|
||||
if end_date:
|
||||
from datetime import datetime
|
||||
end_dt = datetime.fromisoformat(end_date)
|
||||
steps_query = steps_query.filter(HealthMetric.date <= end_dt.date())
|
||||
|
||||
# Calculate total steps
|
||||
total_steps_result = steps_query.scalar()
|
||||
total_steps = int(total_steps_result) if total_steps_result is not None else 0
|
||||
|
||||
# Build query for heart rate
|
||||
hr_query = db.query(func.avg(HealthMetric.metric_value)).filter(HealthMetric.metric_type == 'heart_rate')
|
||||
|
||||
# Apply date filters to heart rate query
|
||||
if start_date:
|
||||
from datetime import datetime
|
||||
start_dt = datetime.fromisoformat(start_date)
|
||||
hr_query = hr_query.filter(HealthMetric.date >= start_dt.date())
|
||||
|
||||
if end_date:
|
||||
end_dt = datetime.fromisoformat(end_date)
|
||||
hr_query = hr_query.filter(HealthMetric.date <= end_dt.date())
|
||||
|
||||
# Calculate average heart rate
|
||||
avg_hr_result = hr_query.scalar()
|
||||
avg_heart_rate = float(avg_hr_result) if avg_hr_result is not None else 0.0
|
||||
|
||||
# Calculate total sleep hours - assuming sleep data is stored in minutes in the database
|
||||
sleep_query = db.query(func.sum(HealthMetric.metric_value)).filter(HealthMetric.metric_type == 'sleep')
|
||||
|
||||
# Apply date filters to sleep query
|
||||
if start_date:
|
||||
from datetime import datetime
|
||||
start_dt = datetime.fromisoformat(start_date)
|
||||
sleep_query = sleep_query.filter(HealthMetric.date >= start_dt.date())
|
||||
|
||||
if end_date:
|
||||
end_dt = datetime.fromisoformat(end_date)
|
||||
sleep_query = sleep_query.filter(HealthMetric.date <= end_dt.date())
|
||||
|
||||
# Calculate total sleep in minutes, then convert to hours
|
||||
total_sleep_minutes_result = sleep_query.scalar()
|
||||
total_sleep_hours = (total_sleep_minutes_result / 60) if total_sleep_minutes_result is not None else 0.0
|
||||
|
||||
# Calculate average calories - assuming we have calories data
|
||||
calories_query = db.query(func.avg(HealthMetric.metric_value)).filter(HealthMetric.metric_type == 'calories')
|
||||
|
||||
# Apply date filters to calories query
|
||||
if start_date:
|
||||
from datetime import datetime
|
||||
start_dt = datetime.fromisoformat(start_date)
|
||||
calories_query = calories_query.filter(HealthMetric.date >= start_dt.date())
|
||||
|
||||
if end_date:
|
||||
end_dt = datetime.fromisoformat(end_date)
|
||||
calories_query = calories_query.filter(HealthMetric.date <= end_dt.date())
|
||||
|
||||
# Calculate average calories
|
||||
avg_calories_result = calories_query.scalar()
|
||||
avg_calories = float(avg_calories_result) if avg_calories_result is not None else 0.0
|
||||
|
||||
summary = HealthDataSummary(
|
||||
total_steps=total_steps,
|
||||
avg_heart_rate=round(avg_heart_rate, 2),
|
||||
total_sleep_hours=round(total_sleep_hours, 2),
|
||||
avg_calories=round(avg_calories, 2)
|
||||
)
|
||||
|
||||
logger.info(f"Returning health summary: steps={total_steps}, avg_hr={avg_heart_rate}, sleep_hours={total_sleep_hours}, avg_calories={avg_calories}")
|
||||
return summary
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_health_summary: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Error getting health summary: {str(e)}")
|
||||
@@ -3,16 +3,14 @@ from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
import traceback
|
||||
import httpx
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
from ..services.garmin.client import GarminClient
|
||||
from ..services.postgresql_manager import PostgreSQLManager
|
||||
from ..utils.config import config
|
||||
import garth
|
||||
from ..services.garmin.client import GarminClient
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_db():
|
||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
||||
@@ -24,260 +22,35 @@ class GarminCredentials(BaseModel):
|
||||
password: str
|
||||
is_china: bool = False
|
||||
|
||||
class FitbitCredentials(BaseModel):
|
||||
client_id: str
|
||||
client_secret: str
|
||||
|
||||
class FitbitCallback(BaseModel):
|
||||
callback_url: str
|
||||
|
||||
class GarminMFARequest(BaseModel):
|
||||
verification_code: str
|
||||
session_id: str
|
||||
|
||||
class AuthStatusResponse(BaseModel):
|
||||
garmin: Optional[dict] = None
|
||||
fitbit: Optional[dict] = None
|
||||
|
||||
class AuthStatusResponse(BaseModel):
|
||||
garmin: Optional[dict] = None
|
||||
fitbit: Optional[dict] = None
|
||||
|
||||
@router.post("/setup/load-consul-config")
|
||||
async def load_consul_config(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Load configuration from Consul and save it to the database.
|
||||
It first tries to use tokens from Consul, if they are not present, it falls back to username/password login.
|
||||
"""
|
||||
consul_url = "http://consul.service.dc1.consul:8500/v1/kv/fitbit-garmin-sync/config"
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(consul_url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not (data and 'Value' in data[0]):
|
||||
raise HTTPException(status_code=404, detail="Config not found in Consul")
|
||||
|
||||
config_value = base64.b64decode(data[0]['Value']).decode('utf-8')
|
||||
config = json.loads(config_value)
|
||||
|
||||
if 'garmin' in config:
|
||||
garmin_config = config['garmin']
|
||||
from ..models.api_token import APIToken
|
||||
from datetime import datetime
|
||||
|
||||
# Prefer tokens if available
|
||||
if 'garth_oauth1_token' in garmin_config and 'garth_oauth2_token' in garmin_config:
|
||||
token_record = db.query(APIToken).filter_by(token_type='garmin').first()
|
||||
if not token_record:
|
||||
token_record = APIToken(token_type='garmin')
|
||||
db.add(token_record)
|
||||
|
||||
token_record.garth_oauth1_token = garmin_config['garth_oauth1_token']
|
||||
token_record.garth_oauth2_token = garmin_config['garth_oauth2_token']
|
||||
token_record.updated_at = datetime.now()
|
||||
db.commit()
|
||||
|
||||
return {"status": "success", "message": "Garmin tokens from Consul have been saved."}
|
||||
|
||||
# Fallback to username/password login
|
||||
elif 'username' in garmin_config and 'password' in garmin_config:
|
||||
garmin_creds = GarminCredentials(**garmin_config)
|
||||
garmin_client = GarminClient(garmin_creds.username, garmin_creds.password, garmin_creds.is_china)
|
||||
status = garmin_client.login()
|
||||
|
||||
if status == "mfa_required":
|
||||
return {"status": "mfa_required", "message": "Garmin login from Consul requires MFA. Please complete it manually."}
|
||||
elif status != "success":
|
||||
raise HTTPException(status_code=400, detail=f"Failed to login to Garmin with Consul credentials: {status}")
|
||||
|
||||
# TODO: Add Fitbit credentials handling
|
||||
|
||||
return {"status": "success", "message": "Configuration from Consul processed."}
|
||||
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to connect to Consul: {e}")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail=f"An error occurred: {e}")
|
||||
|
||||
@router.get("/setup/auth-status", response_model=AuthStatusResponse)
|
||||
async def get_auth_status(db: Session = Depends(get_db)):
|
||||
from ..models.api_token import APIToken
|
||||
|
||||
garmin_status = {}
|
||||
fitbit_status = {}
|
||||
|
||||
# Garmin Status
|
||||
garmin_token = db.query(APIToken).filter_by(token_type='garmin').first()
|
||||
if garmin_token:
|
||||
garmin_status = {
|
||||
"token_stored": True,
|
||||
"authenticated": garmin_token.garth_oauth1_token is not None and garmin_token.garth_oauth2_token is not None,
|
||||
"garth_oauth1_token_exists": garmin_token.garth_oauth1_token is not None,
|
||||
"garth_oauth2_token_exists": garmin_token.garth_oauth2_token is not None,
|
||||
"mfa_state_exists": garmin_token.mfa_state is not None,
|
||||
"mfa_expires_at": garmin_token.mfa_expires_at,
|
||||
"last_used": garmin_token.last_used,
|
||||
"updated_at": garmin_token.updated_at,
|
||||
"username": "N/A", # Placeholder, username is not stored in APIToken
|
||||
"is_china": False # Placeholder
|
||||
}
|
||||
else:
|
||||
garmin_status = {
|
||||
"token_stored": False,
|
||||
"authenticated": False
|
||||
}
|
||||
|
||||
# Fitbit Status (Existing logic, might need adjustment if Fitbit tokens are stored differently)
|
||||
fitbit_token = db.query(APIToken).filter_by(token_type='fitbit').first()
|
||||
if fitbit_token:
|
||||
fitbit_status = {
|
||||
"token_stored": True,
|
||||
"authenticated": fitbit_token.access_token is not None,
|
||||
"client_id": fitbit_token.access_token[:10] + "..." if fitbit_token.access_token else "N/A",
|
||||
"expires_at": fitbit_token.expires_at,
|
||||
"last_used": fitbit_token.last_used,
|
||||
"updated_at": fitbit_token.updated_at
|
||||
}
|
||||
else:
|
||||
fitbit_status = {
|
||||
"token_stored": False,
|
||||
"authenticated": False
|
||||
}
|
||||
|
||||
return AuthStatusResponse(
|
||||
garmin=garmin_status,
|
||||
fitbit=fitbit_status
|
||||
)
|
||||
|
||||
@router.post("/setup/garmin")
|
||||
async def save_garmin_credentials(credentials: GarminCredentials, db: Session = Depends(get_db)):
|
||||
from ..utils.helpers import setup_logger
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
logger.info(f"Received Garmin credentials for user: {credentials.username}, is_china: {credentials.is_china}")
|
||||
def save_garmin_credentials(credentials: GarminCredentials, db: Session = Depends(get_db)):
|
||||
logger.info(f"Received Garmin credentials for user: {credentials.username}")
|
||||
|
||||
garmin_client = GarminClient(credentials.username, credentials.password, credentials.is_china)
|
||||
logger.debug("GarminClient instance created successfully")
|
||||
|
||||
logger.debug("Attempting to log in to Garmin")
|
||||
# Check the status returned directly
|
||||
status = garmin_client.login()
|
||||
status = garmin_client.login(db)
|
||||
|
||||
if status == "mfa_required":
|
||||
# Hardcode the session_id as 'garmin' since you use a single record in APIToken
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"status": "mfa_required",
|
||||
"message": "MFA Required",
|
||||
"session_id": "garmin"
|
||||
}
|
||||
)
|
||||
return JSONResponse(status_code=202, content={"status": "mfa_required", "message": "MFA code required."})
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"status": "success", "message": "Logged in!"}
|
||||
)
|
||||
return JSONResponse(status_code=200, content={"status": "success", "message": "Logged in and tokens saved."})
|
||||
|
||||
@router.post("/setup/garmin/mfa")
|
||||
async def complete_garmin_mfa(mfa_request: GarminMFARequest, db: Session = Depends(get_db)):
|
||||
from ..utils.helpers import setup_logger
|
||||
logger = setup_logger(__name__)
|
||||
def complete_garmin_mfa(mfa_request: GarminMFARequest, db: Session = Depends(get_db)):
|
||||
logger.info(f"Received MFA verification code: {'*' * len(mfa_request.verification_code)}")
|
||||
|
||||
try:
|
||||
logger.info(f"Received MFA verification code for session {mfa_request.session_id}: {'*' * len(mfa_request.verification_code)}")
|
||||
garmin_client = GarminClient()
|
||||
success = garmin_client.handle_mfa(db, mfa_request.verification_code)
|
||||
|
||||
try:
|
||||
garmin_client = GarminClient()
|
||||
logger.debug(f"Attempting to handle MFA for session: {mfa_request.session_id}")
|
||||
if success:
|
||||
return JSONResponse(status_code=200, content={"status": "success", "message": "MFA verification successful, tokens saved."})
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="MFA verification failed.")
|
||||
|
||||
success = garmin_client.handle_mfa(mfa_request.verification_code, session_id=mfa_request.session_id)
|
||||
|
||||
if success:
|
||||
logger.info(f"MFA verification completed successfully for session: {mfa_request.session_id}")
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"status": "success", "message": "MFA verification completed successfully"}
|
||||
)
|
||||
else:
|
||||
logger.error(f"MFA verification failed for session: {mfa_request.session_id}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "MFA verification failed"}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"MFA verification failed for session {mfa_request.session_id} with exception: {str(e)}")
|
||||
logger.error(f"Exception type: {type(e).__name__}")
|
||||
logger.error(f"Exception details: {repr(e)}")
|
||||
logger.error(f"Full traceback: {traceback.format_exc()}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": f"MFA verification failed: {str(e)}"}
|
||||
)
|
||||
except Exception as outer_error:
|
||||
logger.error(f"Unexpected error in complete_garmin_mfa: {str(outer_error)}")
|
||||
logger.error(f"Full traceback: {traceback.format_exc()}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": f"Unexpected error: {str(outer_error)}"}
|
||||
)
|
||||
|
||||
@router.post("/setup/fitbit")
|
||||
async def save_fitbit_credentials(credentials: FitbitCredentials, db: Session = Depends(get_db)):
|
||||
return {
|
||||
"status": "success",
|
||||
"auth_url": "https://www.fitbit.com/oauth2/authorize?...",
|
||||
"message": "Fitbit credentials saved, please visit auth_url to authorize"
|
||||
}
|
||||
|
||||
@router.post("/setup/fitbit/callback")
|
||||
async def fitbit_callback(callback_data: FitbitCallback, db: Session = Depends(get_db)):
|
||||
return {"status": "success", "message": "Fitbit OAuth flow completed successfully"}
|
||||
|
||||
@router.post("/setup/garmin/test-token")
|
||||
async def test_garmin_token(db: Session = Depends(get_db)):
|
||||
from ..models.api_token import APIToken
|
||||
from garth.auth_tokens import OAuth1Token, OAuth2Token
|
||||
import json
|
||||
|
||||
token_record = db.query(APIToken).filter_by(token_type='garmin').first()
|
||||
if not token_record or not token_record.garth_oauth1_token or not token_record.garth_oauth2_token:
|
||||
raise HTTPException(status_code=404, detail="Garmin token not found or incomplete.")
|
||||
|
||||
try:
|
||||
from ..utils.helpers import setup_logger
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
logger.info("garth_oauth1_token from DB: %s", token_record.garth_oauth1_token)
|
||||
logger.info("Type of garth_oauth1_token: %s", type(token_record.garth_oauth1_token))
|
||||
logger.info("garth_oauth2_token from DB: %s", token_record.garth_oauth2_token)
|
||||
logger.info("Type of garth_oauth2_token: %s", type(token_record.garth_oauth2_token))
|
||||
|
||||
if not token_record.garth_oauth1_token or not token_record.garth_oauth2_token:
|
||||
raise HTTPException(status_code=400, detail="OAuth1 or OAuth2 token is empty.")
|
||||
|
||||
import garth
|
||||
|
||||
# Parse JSON to dictionaries
|
||||
oauth1_dict = json.loads(token_record.garth_oauth1_token)
|
||||
oauth2_dict = json.loads(token_record.garth_oauth2_token)
|
||||
|
||||
# Convert to proper token objects
|
||||
garth.client.oauth1_token = OAuth1Token(**oauth1_dict)
|
||||
garth.client.oauth2_token = OAuth2Token(**oauth2_dict)
|
||||
|
||||
# Also configure the domain if present
|
||||
if oauth1_dict.get('domain'):
|
||||
garth.configure(domain=oauth1_dict['domain'])
|
||||
|
||||
profile_info = garth.UserProfile.get()
|
||||
return profile_info
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail=f"Failed to test Garmin token: {e}")
|
||||
|
||||
logger.error(f"MFA verification failed with exception: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"MFA verification failed: {str(e)}")
|
||||
|
||||
@@ -1,36 +1,48 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from ..services.postgresql_manager import PostgreSQLManager
|
||||
from ..utils.config import config
|
||||
from ..models.activity import Activity
|
||||
from ..models.sync_log import SyncLog
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def get_db():
|
||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
||||
with db_manager.get_db_session() as session:
|
||||
yield session
|
||||
|
||||
class SyncLogResponse(BaseModel):
|
||||
id: int
|
||||
operation: str
|
||||
status: str
|
||||
message: Optional[str]
|
||||
start_time: str
|
||||
end_time: Optional[str]
|
||||
message: Optional[str] = None
|
||||
start_time: datetime
|
||||
end_time: Optional[datetime] = None
|
||||
records_processed: int
|
||||
records_failed: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
total_weight_records: int
|
||||
synced_weight_records: int
|
||||
unsynced_weight_records: int
|
||||
total_activities: int
|
||||
downloaded_activities: int
|
||||
recent_logs: List[SyncLogResponse]
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status():
|
||||
# This would return the current sync status
|
||||
# Implementation will connect with the services layer
|
||||
return {
|
||||
"total_weight_records": 100,
|
||||
"synced_weight_records": 85,
|
||||
"unsynced_weight_records": 15,
|
||||
"total_activities": 50,
|
||||
"downloaded_activities": 30,
|
||||
"recent_logs": []
|
||||
}
|
||||
@router.get("/status", response_model=StatusResponse)
|
||||
def get_status(db: Session = Depends(get_db)):
|
||||
"""Returns the current sync status and recent logs."""
|
||||
total_activities = db.query(Activity).count()
|
||||
downloaded_activities = db.query(Activity).filter(Activity.download_status == 'downloaded').count()
|
||||
|
||||
recent_logs = db.query(SyncLog).order_by(SyncLog.start_time.desc()).limit(10).all()
|
||||
|
||||
return StatusResponse(
|
||||
total_activities=total_activities,
|
||||
downloaded_activities=downloaded_activities,
|
||||
recent_logs=recent_logs
|
||||
)
|
||||
@@ -1,11 +1,20 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from ..models.api_token import APIToken
|
||||
from ..services.sync_app import SyncApp
|
||||
from ..services.garmin.client import GarminClient
|
||||
from ..services.postgresql_manager import PostgreSQLManager
|
||||
from sqlalchemy.orm import Session
|
||||
from ..utils.config import config
|
||||
import logging
|
||||
import json
|
||||
import garth
|
||||
from garth.auth_tokens import OAuth1Token, OAuth2Token
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SyncActivityRequest(BaseModel):
|
||||
days_back: int = 30
|
||||
@@ -20,32 +29,50 @@ def get_db():
|
||||
with db_manager.get_db_session() as session:
|
||||
yield session
|
||||
|
||||
@router.post("/sync/weight", response_model=SyncResponse)
|
||||
async def sync_weight(db: Session = Depends(get_db)):
|
||||
# This would trigger the weight sync process
|
||||
# Implementation will connect with the services layer
|
||||
return {
|
||||
"status": "started",
|
||||
"message": "Weight sync process started",
|
||||
"job_id": "weight-sync-12345"
|
||||
}
|
||||
def _load_and_verify_garth_session(db: Session):
|
||||
"""Helper to load token from DB and verify session with Garmin."""
|
||||
logger.info("Loading and verifying Garmin session...")
|
||||
token_record = db.query(APIToken).filter_by(token_type='garmin').first()
|
||||
if not (token_record and token_record.garth_oauth1_token and token_record.garth_oauth2_token):
|
||||
raise HTTPException(status_code=401, detail="Garmin token not found.")
|
||||
|
||||
try:
|
||||
oauth1_dict = json.loads(token_record.garth_oauth1_token)
|
||||
oauth2_dict = json.loads(token_record.garth_oauth2_token)
|
||||
|
||||
domain = oauth1_dict.get('domain')
|
||||
if domain:
|
||||
garth.configure(domain=domain)
|
||||
|
||||
garth.client.oauth1_token = OAuth1Token(**oauth1_dict)
|
||||
garth.client.oauth2_token = OAuth2Token(**oauth2_dict)
|
||||
|
||||
garth.UserProfile.get()
|
||||
logger.info("Garth session verified.")
|
||||
except Exception as e:
|
||||
logger.error(f"Garth session verification failed: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=401, detail=f"Failed to authenticate with Garmin: {e}")
|
||||
|
||||
@router.post("/sync/activities", response_model=SyncResponse)
|
||||
async def sync_activities(request: SyncActivityRequest, db: Session = Depends(get_db)):
|
||||
# This would trigger the activity sync process
|
||||
# Implementation will connect with the services layer
|
||||
return {
|
||||
"status": "started",
|
||||
"message": "Activity sync process started",
|
||||
"job_id": f"activity-sync-{request.days_back}"
|
||||
}
|
||||
def sync_activities(request: SyncActivityRequest, db: Session = Depends(get_db)):
|
||||
_load_and_verify_garth_session(db)
|
||||
garmin_client = GarminClient() # The client is now just a thin wrapper
|
||||
sync_app = SyncApp(db_session=db, garmin_client=garmin_client)
|
||||
result = sync_app.sync_activities(days_back=request.days_back)
|
||||
return SyncResponse(
|
||||
status=result.get("status", "completed_with_errors" if result.get("failed", 0) > 0 else "completed"),
|
||||
message=f"Activity sync completed: {result.get('processed', 0)} processed, {result.get('failed', 0)} failed",
|
||||
job_id=f"activity-sync-{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||
)
|
||||
|
||||
@router.post("/sync/metrics", response_model=SyncResponse)
|
||||
async def sync_metrics(db: Session = Depends(get_db)):
|
||||
# This would trigger the health metrics sync process
|
||||
# Implementation will connect with the services layer
|
||||
return {
|
||||
"status": "started",
|
||||
"message": "Health metrics sync process started",
|
||||
"job_id": "metrics-sync-12345"
|
||||
}
|
||||
def sync_metrics(db: Session = Depends(get_db)):
|
||||
_load_and_verify_garth_session(db)
|
||||
garmin_client = GarminClient()
|
||||
sync_app = SyncApp(db_session=db, garmin_client=garmin_client)
|
||||
result = sync_app.sync_health_metrics()
|
||||
return SyncResponse(
|
||||
status=result.get("status", "completed_with_errors" if result.get("failed", 0) > 0 else "completed"),
|
||||
message=f"Health metrics sync completed: {result.get('processed', 0)} processed, {result.get('failed', 0)} failed",
|
||||
job_id=f"metrics-sync-{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||
)
|
||||
|
||||
@@ -2,103 +2,89 @@ import garth
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from garth.exc import GarthException
|
||||
from src.models.api_token import APIToken
|
||||
from src.services.postgresql_manager import PostgreSQLManager
|
||||
from src.utils.config import config
|
||||
from src.utils.helpers import setup_logger
|
||||
from sqlalchemy.orm import Session
|
||||
import logging
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
from ...models.api_token import APIToken
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AuthMixin:
|
||||
def login(self):
|
||||
def login(self, db: Session):
|
||||
"""Login to Garmin Connect, returning status instead of raising exceptions."""
|
||||
logger.info(f"Starting login for: {self.username}")
|
||||
try:
|
||||
# result1 is status, result2 is the mfa_state dict or tokens
|
||||
result1, result2 = garth.login(self.username, self.password, return_on_mfa=True)
|
||||
|
||||
if result1 == "needs_mfa":
|
||||
logger.info("MFA required for Garmin authentication.")
|
||||
self.initiate_mfa(result2) # Fixed below
|
||||
return "mfa_required"
|
||||
|
||||
self.update_tokens(result1, result2)
|
||||
garth.login(self.username, self.password)
|
||||
self.update_tokens(db, garth.client.oauth1_token, garth.client.oauth2_token)
|
||||
self.is_connected = True
|
||||
return "success"
|
||||
except GarthException as e:
|
||||
if "needs-mfa" in str(e).lower():
|
||||
logger.info("MFA required for Garmin authentication.")
|
||||
self.initiate_mfa(db, garth.client.mfa_state)
|
||||
return "mfa_required"
|
||||
logger.error(f"Login failed: {e}")
|
||||
return "error"
|
||||
|
||||
def update_tokens(self, oauth1, oauth2):
|
||||
def update_tokens(self, db: Session, oauth1: dict, oauth2: dict):
|
||||
"""Saves the Garmin OAuth tokens to the database."""
|
||||
logger.info(f"Updating Garmin tokens for user: {self.username}")
|
||||
|
||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
||||
with db_manager.get_db_session() as session:
|
||||
token_record = session.query(APIToken).filter_by(token_type='garmin').first()
|
||||
if not token_record:
|
||||
token_record = APIToken(token_type='garmin')
|
||||
session.add(token_record)
|
||||
|
||||
token_record.garth_oauth1_token = json.dumps(oauth1)
|
||||
token_record.garth_oauth2_token = json.dumps(oauth2)
|
||||
token_record.updated_at = datetime.now()
|
||||
|
||||
# Clear MFA state as it's no longer needed
|
||||
token_record.mfa_state = None
|
||||
token_record.mfa_expires_at = None
|
||||
|
||||
session.commit()
|
||||
logger.info("Garmin tokens updated successfully.")
|
||||
token_record = db.query(APIToken).filter_by(token_type='garmin').first()
|
||||
if not token_record:
|
||||
token_record = APIToken(token_type='garmin')
|
||||
db.add(token_record)
|
||||
|
||||
token_record.garth_oauth1_token = json.dumps(oauth1)
|
||||
token_record.garth_oauth2_token = json.dumps(oauth2)
|
||||
token_record.updated_at = datetime.now()
|
||||
|
||||
token_record.mfa_state = None
|
||||
token_record.mfa_expires_at = None
|
||||
|
||||
db.commit()
|
||||
logger.info("Garmin tokens updated successfully.")
|
||||
|
||||
def initiate_mfa(self, mfa_state):
|
||||
def initiate_mfa(self, db: Session, mfa_state: dict):
|
||||
"""Saves ONLY serializable parts of the MFA state to the database."""
|
||||
logger.info(f"Initiating MFA process for user: {self.username}")
|
||||
|
||||
# FIX: Extract serializable data. We cannot dump the 'client' object directly.
|
||||
serializable_state = {
|
||||
"signin_params": mfa_state["signin_params"],
|
||||
"cookies": mfa_state["client"].sess.cookies.get_dict(),
|
||||
"domain": mfa_state["client"].domain
|
||||
}
|
||||
|
||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
||||
with db_manager.get_db_session() as session:
|
||||
token_record = session.query(APIToken).filter_by(token_type='garmin').first()
|
||||
if not token_record:
|
||||
token_record = APIToken(token_type='garmin')
|
||||
session.add(token_record)
|
||||
|
||||
# Save the dictionary as a string
|
||||
token_record.mfa_state = json.dumps(serializable_state)
|
||||
token_record.mfa_expires_at = datetime.now() + timedelta(minutes=10)
|
||||
session.commit()
|
||||
token_record = db.query(APIToken).filter_by(token_type='garmin').first()
|
||||
if not token_record:
|
||||
token_record = APIToken(token_type='garmin')
|
||||
db.add(token_record)
|
||||
|
||||
token_record.mfa_state = json.dumps(serializable_state)
|
||||
token_record.mfa_expires_at = datetime.now() + timedelta(minutes=10)
|
||||
db.commit()
|
||||
|
||||
def handle_mfa(self, verification_code: str, session_id: str = None):
|
||||
def handle_mfa(self, db: Session, verification_code: str):
|
||||
"""Reconstructs the Garth state and completes authentication."""
|
||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
||||
with db_manager.get_db_session() as session:
|
||||
token_record = session.query(APIToken).filter_by(token_type='garmin').first()
|
||||
if not token_record or not token_record.mfa_state:
|
||||
raise Exception("No pending MFA session found.")
|
||||
|
||||
saved_data = json.loads(token_record.mfa_state)
|
||||
|
||||
# FIX: Reconstruct the Garth Client and State object
|
||||
from garth.http import Client
|
||||
client = Client(domain=saved_data["domain"])
|
||||
client.sess.cookies.update(saved_data["cookies"])
|
||||
|
||||
mfa_state = {
|
||||
"client": client,
|
||||
"signin_params": saved_data["signin_params"]
|
||||
}
|
||||
|
||||
try:
|
||||
oauth1, oauth2 = garth.resume_login(mfa_state, verification_code)
|
||||
self.update_tokens(oauth1, oauth2)
|
||||
# ... rest of your session cleanup ...
|
||||
return True
|
||||
except GarthException as e:
|
||||
logger.error(f"MFA handling failed: {e}")
|
||||
raise
|
||||
token_record = db.query(APIToken).filter_by(token_type='garmin').first()
|
||||
if not token_record or not token_record.mfa_state:
|
||||
raise Exception("No pending MFA session found.")
|
||||
|
||||
saved_data = json.loads(token_record.mfa_state)
|
||||
|
||||
from garth.http import Client
|
||||
client = Client(domain=saved_data["domain"])
|
||||
client.sess.cookies.update(saved_data["cookies"])
|
||||
|
||||
mfa_state = {
|
||||
"client": client,
|
||||
"signin_params": saved_data["signin_params"]
|
||||
}
|
||||
|
||||
try:
|
||||
garth.resume_login(mfa_state, verification_code)
|
||||
self.update_tokens(db, garth.client.oauth1_token, garth.client.oauth2_token)
|
||||
return True
|
||||
except GarthException as e:
|
||||
logger.error(f"MFA handling failed: {e}")
|
||||
raise
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import garth
|
||||
from src.utils.helpers import setup_logger
|
||||
import logging
|
||||
from .auth import AuthMixin
|
||||
from .data import DataMixin
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class GarminClient(AuthMixin, DataMixin):
|
||||
def __init__(self, username: str = None, password: str = None, is_china: bool = False):
|
||||
@@ -13,10 +13,7 @@ class GarminClient(AuthMixin, DataMixin):
|
||||
self.garmin_client = None
|
||||
self.is_connected = False
|
||||
|
||||
logger.debug(f"Initializing GarminClient for user: {username}, is_china: {is_china}")
|
||||
|
||||
if is_china:
|
||||
logger.debug("Configuring garth for China domain")
|
||||
garth.configure(domain="garmin.cn")
|
||||
|
||||
if username and password:
|
||||
|
||||
@@ -1,139 +1,75 @@
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any, Optional
|
||||
from src.utils.helpers import setup_logger
|
||||
import garth
|
||||
from garth.stats.steps import DailySteps
|
||||
from garth.stats.hrv import DailyHRV
|
||||
from garth.data.sleep import SleepData
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
class DataMixin:
|
||||
def upload_weight(self, weight: float, unit: str = 'kg', timestamp: datetime = None) -> bool:
|
||||
"""Upload weight entry to Garmin Connect."""
|
||||
if not self.is_connected:
|
||||
raise Exception("Not connected to Garmin Connect")
|
||||
|
||||
try:
|
||||
if not timestamp:
|
||||
timestamp = datetime.now()
|
||||
|
||||
try:
|
||||
result = self.garmin_client.add_body_composition(
|
||||
timestamp=timestamp,
|
||||
weight=weight
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
result = self.garmin_client.add_body_composition(
|
||||
timestamp=timestamp.isoformat(),
|
||||
weight=weight
|
||||
)
|
||||
except Exception:
|
||||
result = self.garmin_client.add_body_composition(
|
||||
timestamp=timestamp.strftime('%Y-%m-%d'),
|
||||
weight=weight
|
||||
)
|
||||
|
||||
logger.info(f"Successfully uploaded weight: {weight} {unit} at {timestamp}")
|
||||
return result is not None
|
||||
except Exception as e:
|
||||
logger.error(f"Error uploading weight to Garmin: {str(e)}")
|
||||
if "401" in str(e) or "unauthorized" in str(e).lower():
|
||||
logger.error("Authentication failed - need to re-authenticate")
|
||||
raise Exception("Authentication expired, needs re-authentication")
|
||||
raise e
|
||||
"""
|
||||
Mixin for Garmin data fetching operations using the garth library.
|
||||
Assumes that the global garth client has been authenticated.
|
||||
"""
|
||||
|
||||
def get_activities(self, start_date: str, end_date: str = None, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
def get_activities(self, start_date: str, end_date: str, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
"""Fetch activity list from Garmin Connect."""
|
||||
if not self.is_connected:
|
||||
raise Exception("Not connected to Garmin Connect")
|
||||
|
||||
logger.info(f"Fetching activities from {start_date} to {end_date}")
|
||||
try:
|
||||
if not end_date:
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
activities = self.garmin_client.get_activities(start_date, end_date)
|
||||
logger.info(f"Fetched {len(activities)} activities from Garmin")
|
||||
return activities
|
||||
return garth.client.connectapi(
|
||||
"/activitylist-service/activities/search/activities",
|
||||
params={"startDate": start_date, "endDate": end_date, "limit": limit}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching activities from Garmin: {str(e)}")
|
||||
raise e
|
||||
logger.error(f"Error fetching activities from Garmin: {e}")
|
||||
raise
|
||||
|
||||
def download_activity(self, activity_id: str, file_type: str = 'tcx') -> Optional[bytes]:
|
||||
"""Download activity file from Garmin Connect and return its content."""
|
||||
if not self.is_connected:
|
||||
raise Exception("Not connected to Garmin Connect")
|
||||
|
||||
def download_activity(self, activity_id: str, file_type: str = 'original') -> Optional[bytes]:
|
||||
"""
|
||||
Download an activity file from Garmin Connect.
|
||||
'file_type' can be 'tcx', 'gpx', 'fit', or 'original'.
|
||||
"""
|
||||
logger.info(f"Downloading activity {activity_id} as {file_type}")
|
||||
try:
|
||||
file_content = self.garmin_client.get_activity_details(activity_id)
|
||||
logger.info(f"Downloaded activity {activity_id} as {file_type} format")
|
||||
return file_content if file_content else b""
|
||||
path = f"/download-service/export/{file_type}/activity/{activity_id}"
|
||||
return garth.client.download(path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading activity {activity_id} from Garmin: {str(e)}")
|
||||
raise e
|
||||
logger.error(f"Error downloading activity {activity_id} as {file_type}: {e}")
|
||||
return None
|
||||
|
||||
def get_heart_rates(self, start_date: str, end_date: str = None) -> Dict[str, Any]:
|
||||
"""Fetch heart rate data from Garmin Connect."""
|
||||
if not self.is_connected:
|
||||
raise Exception("Not connected to Garmin Connect")
|
||||
|
||||
try:
|
||||
if not end_date:
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
heart_rates = self.garmin_client.get_heart_rates(start_date, end_date)
|
||||
logger.info(f"Fetched heart rate data from Garmin for {start_date} to {end_date}")
|
||||
return heart_rates
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching heart rate data from Garmin: {str(e)}")
|
||||
raise e
|
||||
def get_daily_metrics(self, start_date: str, end_date: str) -> Dict[str, List[Dict]]:
|
||||
"""
|
||||
Fetch various daily metrics for a given date range.
|
||||
"""
|
||||
start = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
end = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
days = (end - start).days + 1
|
||||
|
||||
def get_sleep_data(self, start_date: str, end_date: str = None) -> Dict[str, Any]:
|
||||
"""Fetch sleep data from Garmin Connect."""
|
||||
if not self.is_connected:
|
||||
raise Exception("Not connected to Garmin Connect")
|
||||
|
||||
try:
|
||||
if not end_date:
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
sleep_data = self.garmin_client.get_sleep_data(start_date, end_date)
|
||||
logger.info(f"Fetched sleep data from Garmin for {start_date} to {end_date}")
|
||||
return sleep_data
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching sleep data from Garmin: {str(e)}")
|
||||
raise e
|
||||
all_metrics = {
|
||||
"steps": [],
|
||||
"hrv": [],
|
||||
"sleep": []
|
||||
}
|
||||
|
||||
def get_steps_data(self, start_date: str, end_date: str = None) -> Dict[str, Any]:
|
||||
"""Fetch steps data from Garmin Connect."""
|
||||
if not self.is_connected:
|
||||
raise Exception("Not connected to Garmin Connect")
|
||||
|
||||
try:
|
||||
if not end_date:
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
steps_data = self.garmin_client.get_steps_data(start_date, end_date)
|
||||
logger.info(f"Fetched steps data from Garmin for {start_date} to {end_date}")
|
||||
return steps_data
|
||||
logger.info(f"Fetching daily steps for {days} days ending on {end_date}")
|
||||
all_metrics["steps"] = DailySteps.list(end, period=days)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching steps data from Garmin: {str(e)}")
|
||||
raise e
|
||||
logger.error(f"Error fetching daily steps: {e}")
|
||||
|
||||
def get_all_metrics(self, start_date: str, end_date: str = None) -> Dict[str, Any]:
|
||||
"""Fetch all available metrics from Garmin Connect."""
|
||||
if not self.is_connected:
|
||||
raise Exception("Not connected to Garmin Connect")
|
||||
|
||||
try:
|
||||
if not end_date:
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
metrics = {
|
||||
'heart_rates': self.get_heart_rates(start_date, end_date),
|
||||
'sleep_data': self.get_sleep_data(start_date, end_date),
|
||||
'steps_data': self.get_steps_data(start_date, end_date),
|
||||
}
|
||||
|
||||
logger.info(f"Fetched all metrics from Garmin for {start_date} to {end_date}")
|
||||
return metrics
|
||||
logger.info(f"Fetching daily HRV for {days} days ending on {end_date}")
|
||||
all_metrics["hrv"] = DailyHRV.list(end, period=days)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching all metrics from Garmin: {str(e)}")
|
||||
raise e
|
||||
logger.error(f"Error fetching daily HRV: {e}")
|
||||
|
||||
try:
|
||||
logger.info(f"Fetching daily sleep for {days} days ending on {end_date}")
|
||||
all_metrics["sleep"] = SleepData.list(end, days=days)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching daily sleep: {e}")
|
||||
|
||||
return all_metrics
|
||||
|
||||
@@ -1,322 +1,182 @@
|
||||
from ..models.weight_record import WeightRecord
|
||||
from ..models.activity import Activity
|
||||
from ..models.health_metric import HealthMetric
|
||||
from ..models.sync_log import SyncLog
|
||||
from ..services.fitbit_client import FitbitClient
|
||||
from ..services.garmin.client import GarminClient
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict
|
||||
import logging
|
||||
from ..utils.helpers import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SyncApp:
|
||||
def __init__(self, db_session: Session, fitbit_client: FitbitClient, garmin_client: GarminClient):
|
||||
def __init__(self, db_session: Session, garmin_client: GarminClient, fitbit_client=None):
|
||||
self.db_session = db_session
|
||||
self.fitbit_client = fitbit_client
|
||||
self.garmin_client = garmin_client
|
||||
|
||||
def sync_weight_data(self, start_date: str = None, end_date: str = None) -> Dict[str, int]:
|
||||
"""Sync weight data from Fitbit to Garmin."""
|
||||
if not start_date:
|
||||
# Default to 1 year back
|
||||
start_date = (datetime.now() - timedelta(days=365)).strftime('%Y-%m-%d')
|
||||
|
||||
if not end_date:
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
# Create a sync log entry
|
||||
sync_log = SyncLog(
|
||||
operation="weight_sync",
|
||||
status="started",
|
||||
start_time=datetime.now(),
|
||||
records_processed=0,
|
||||
records_failed=0
|
||||
)
|
||||
self.db_session.add(sync_log)
|
||||
self.db_session.commit()
|
||||
|
||||
try:
|
||||
# Fetch unsynced weight records from Fitbit
|
||||
fitbit_weights = self.fitbit_client.get_weight_logs(start_date, end_date)
|
||||
|
||||
# Track processing results
|
||||
processed_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for weight_entry in fitbit_weights:
|
||||
try:
|
||||
# Check if this weight entry already exists in our DB (prevents duplicates)
|
||||
fitbit_id = weight_entry.get('logId', str(weight_entry.get('date', '') + str(weight_entry.get('weight', 0))))
|
||||
|
||||
existing_record = self.db_session.query(WeightRecord).filter(
|
||||
WeightRecord.fitbit_id == fitbit_id
|
||||
).first()
|
||||
|
||||
if existing_record and existing_record.sync_status == 'synced':
|
||||
# Skip if already synced
|
||||
continue
|
||||
|
||||
# Create or update weight record
|
||||
if not existing_record:
|
||||
weight_record = WeightRecord(
|
||||
fitbit_id=fitbit_id,
|
||||
weight=weight_entry.get('weight'),
|
||||
unit=weight_entry.get('unit', 'kg'),
|
||||
date=datetime.fromisoformat(weight_entry.get('date')) if isinstance(weight_entry.get('date'), str) else weight_entry.get('date'),
|
||||
timestamp=datetime.fromisoformat(weight_entry.get('date')) if isinstance(weight_entry.get('date'), str) else weight_entry.get('date'),
|
||||
sync_status='unsynced'
|
||||
)
|
||||
self.db_session.add(weight_record)
|
||||
self.db_session.flush() # Get the ID
|
||||
else:
|
||||
weight_record = existing_record
|
||||
|
||||
# Upload to Garmin if not already synced
|
||||
if weight_record.sync_status != 'synced':
|
||||
# Upload weight to Garmin
|
||||
success = self.garmin_client.upload_weight(
|
||||
weight=weight_record.weight,
|
||||
unit=weight_record.unit,
|
||||
timestamp=weight_record.timestamp
|
||||
)
|
||||
|
||||
if success:
|
||||
weight_record.sync_status = 'synced'
|
||||
weight_record.garmin_id = "garmin_" + fitbit_id # Placeholder for Garmin ID
|
||||
else:
|
||||
weight_record.sync_status = 'failed'
|
||||
failed_count += 1
|
||||
|
||||
processed_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing weight entry: {str(e)}")
|
||||
failed_count += 1
|
||||
|
||||
# Update sync log with results
|
||||
sync_log.status = "completed" if failed_count == 0 else "completed_with_errors"
|
||||
sync_log.end_time = datetime.now()
|
||||
sync_log.records_processed = processed_count
|
||||
sync_log.records_failed = failed_count
|
||||
|
||||
self.db_session.commit()
|
||||
|
||||
logger.info(f"Weight sync completed: {processed_count} processed, {failed_count} failed")
|
||||
return {
|
||||
"processed": processed_count,
|
||||
"failed": failed_count
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during weight sync: {str(e)}")
|
||||
|
||||
# Update sync log with error status
|
||||
sync_log.status = "failed"
|
||||
sync_log.end_time = datetime.now()
|
||||
sync_log.message = str(e)
|
||||
|
||||
self.db_session.commit()
|
||||
raise e
|
||||
self.fitbit_client = fitbit_client
|
||||
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
|
||||
self.logger.info("SyncApp initialized")
|
||||
|
||||
def sync_activities(self, days_back: int = 30) -> Dict[str, int]:
|
||||
"""Sync activity data from Garmin to local storage."""
|
||||
self.logger.info(f"=== Starting sync_activities with days_back={days_back} ===")
|
||||
|
||||
start_date = (datetime.now() - timedelta(days=days_back)).strftime('%Y-%m-%d')
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
# Create a sync log entry
|
||||
sync_log = SyncLog(
|
||||
operation="activity_archive",
|
||||
status="started",
|
||||
start_time=datetime.now(),
|
||||
records_processed=0,
|
||||
records_failed=0
|
||||
)
|
||||
self.logger.info(f"Date range: {start_date} to {end_date}")
|
||||
|
||||
sync_log = SyncLog(operation="activity_sync", status="started", start_time=datetime.now())
|
||||
self.db_session.add(sync_log)
|
||||
self.db_session.commit()
|
||||
|
||||
processed_count = 0
|
||||
failed_count = 0
|
||||
|
||||
try:
|
||||
# Fetch activities from Garmin
|
||||
self.logger.info("Fetching activities from Garmin...")
|
||||
garmin_activities = self.garmin_client.get_activities(start_date, end_date)
|
||||
self.logger.info(f"Successfully fetched {len(garmin_activities)} activities from Garmin")
|
||||
|
||||
processed_count = 0
|
||||
failed_count = 0
|
||||
|
||||
from ..models.activity import Activity
|
||||
for activity in garmin_activities:
|
||||
for activity_data in garmin_activities:
|
||||
activity_id = str(activity_data.get('activityId'))
|
||||
if not activity_id:
|
||||
self.logger.warning("Skipping activity with no ID.")
|
||||
continue
|
||||
|
||||
try:
|
||||
activity_id = str(activity.get('activityId', ''))
|
||||
existing_activity = self.db_session.query(Activity).filter(
|
||||
Activity.garmin_activity_id == activity_id
|
||||
).first()
|
||||
existing_activity = self.db_session.query(Activity).filter_by(garmin_activity_id=activity_id).first()
|
||||
|
||||
if existing_activity and existing_activity.download_status == 'downloaded':
|
||||
# Skip if already downloaded
|
||||
continue
|
||||
|
||||
# Create or update activity record
|
||||
if not existing_activity:
|
||||
activity_record = Activity(
|
||||
activity_type_dict = activity_data.get('activityType', {})
|
||||
existing_activity = Activity(
|
||||
garmin_activity_id=activity_id,
|
||||
activity_name=activity.get('activityName', ''),
|
||||
activity_type=activity.get('activityType', ''),
|
||||
start_time=datetime.fromisoformat(activity.get('startTimeLocal', '')) if activity.get('startTimeLocal') else None,
|
||||
duration=activity.get('duration', 0),
|
||||
activity_name=activity_data.get('activityName'),
|
||||
activity_type=activity_type_dict.get('typeKey', 'unknown'),
|
||||
start_time=datetime.fromisoformat(activity_data.get('startTimeLocal')) if activity_data.get('startTimeLocal') else None,
|
||||
duration=activity_data.get('duration', 0),
|
||||
download_status='pending'
|
||||
)
|
||||
self.db_session.add(activity_record)
|
||||
self.db_session.flush()
|
||||
else:
|
||||
activity_record = existing_activity
|
||||
self.db_session.add(existing_activity)
|
||||
|
||||
# Download activity file if not already downloaded
|
||||
if activity_record.download_status != 'downloaded':
|
||||
# Download in various formats
|
||||
file_formats = ['tcx', 'gpx', 'fit']
|
||||
if existing_activity.download_status != 'downloaded':
|
||||
downloaded_successfully = False
|
||||
|
||||
for fmt in file_formats:
|
||||
try:
|
||||
# Get file content from Garmin client
|
||||
file_content = self.garmin_client.download_activity(activity_id, file_type=fmt)
|
||||
if file_content:
|
||||
# Store file content directly in the database
|
||||
activity_record.file_content = file_content
|
||||
activity_record.file_type = fmt
|
||||
activity_record.download_status = 'downloaded'
|
||||
activity_record.downloaded_at = datetime.now()
|
||||
downloaded_successfully = True
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not download activity {activity_id} in {fmt} format: {str(e)}")
|
||||
continue
|
||||
for fmt in ['original', 'tcx', 'gpx', 'fit']:
|
||||
file_content = self.garmin_client.download_activity(activity_id, file_type=fmt)
|
||||
if file_content:
|
||||
existing_activity.file_content = file_content
|
||||
existing_activity.file_type = fmt
|
||||
existing_activity.download_status = 'downloaded'
|
||||
existing_activity.downloaded_at = datetime.now()
|
||||
self.logger.info(f"✓ Successfully downloaded {activity_id} as {fmt}")
|
||||
downloaded_successfully = True
|
||||
break
|
||||
|
||||
if not downloaded_successfully:
|
||||
activity_record.download_status = 'failed'
|
||||
existing_activity.download_status = 'failed'
|
||||
self.logger.warning(f"✗ Failed to download {activity_id}")
|
||||
failed_count += 1
|
||||
|
||||
processed_count += 1
|
||||
else:
|
||||
processed_count += 1
|
||||
else:
|
||||
self.logger.info(f"Activity {activity_id} already downloaded. Skipping.")
|
||||
|
||||
self.db_session.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing activity {activity.get('activityId', '')}: {str(e)}")
|
||||
self.logger.error(f"✗ Error processing activity {activity_id}: {e}", exc_info=True)
|
||||
failed_count += 1
|
||||
self.db_session.rollback()
|
||||
|
||||
# Update sync log with results
|
||||
sync_log.status = "completed" if failed_count == 0 else "completed_with_errors"
|
||||
sync_log.end_time = datetime.now()
|
||||
sync_log.status = "completed_with_errors" if failed_count > 0 else "completed"
|
||||
sync_log.records_processed = processed_count
|
||||
sync_log.records_failed = failed_count
|
||||
|
||||
self.db_session.commit()
|
||||
|
||||
logger.info(f"Activity sync completed: {processed_count} processed, {failed_count} failed")
|
||||
return {
|
||||
"processed": processed_count,
|
||||
"failed": failed_count
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during activity sync: {str(e)}")
|
||||
|
||||
# Update sync log with error status
|
||||
self.logger.error(f"Major error during activity sync: {e}", exc_info=True)
|
||||
sync_log.status = "failed"
|
||||
sync_log.end_time = datetime.now()
|
||||
sync_log.message = str(e)
|
||||
|
||||
self.db_session.commit()
|
||||
raise e
|
||||
|
||||
def sync_health_metrics(self, start_date: str = None, end_date: str = None) -> Dict[str, int]:
|
||||
"""Sync health metrics from Garmin to local database."""
|
||||
if not start_date:
|
||||
# Default to 1 year back
|
||||
start_date = (datetime.now() - timedelta(days=365)).strftime('%Y-%m-%d')
|
||||
|
||||
if not end_date:
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
# Create a sync log entry
|
||||
sync_log = SyncLog(
|
||||
operation="metrics_download",
|
||||
status="started",
|
||||
start_time=datetime.now(),
|
||||
records_processed=0,
|
||||
records_failed=0
|
||||
)
|
||||
self.db_session.add(sync_log)
|
||||
sync_log.end_time = datetime.now()
|
||||
self.db_session.commit()
|
||||
|
||||
self.logger.info(f"=== Finished sync_activities: processed={processed_count}, failed={failed_count} ===")
|
||||
return {"processed": processed_count, "failed": failed_count}
|
||||
|
||||
def sync_health_metrics(self, days_back: int = 30) -> Dict[str, int]:
|
||||
"""Sync health metrics from Garmin to local database."""
|
||||
start_date = (datetime.now() - timedelta(days=days_back)).strftime('%Y-%m-%d')
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
self.logger.info(f"=== Starting sync_health_metrics with days_back={days_back} ===")
|
||||
sync_log = SyncLog(operation="health_metric_sync", status="started", start_time=datetime.now())
|
||||
self.db_session.add(sync_log)
|
||||
self.db_session.commit()
|
||||
|
||||
processed_count = 0
|
||||
failed_count = 0
|
||||
|
||||
try:
|
||||
# Fetch all metrics from Garmin
|
||||
all_metrics = self.garmin_client.get_all_metrics(start_date, end_date)
|
||||
|
||||
processed_count = 0
|
||||
failed_count = 0
|
||||
|
||||
from ..models.health_metric import HealthMetric
|
||||
# Process heart rate data
|
||||
heart_rates = all_metrics.get('heart_rates', {})
|
||||
if 'heartRateValues' in heart_rates:
|
||||
for hr_data in heart_rates['heartRateValues']:
|
||||
try:
|
||||
timestamp = datetime.fromisoformat(hr_data[0]) if isinstance(hr_data[0], str) else datetime.fromtimestamp(hr_data[0]/1000)
|
||||
metric = HealthMetric(
|
||||
metric_type='heart_rate',
|
||||
metric_value=hr_data[1],
|
||||
unit='bpm',
|
||||
timestamp=timestamp,
|
||||
date=timestamp.date(),
|
||||
source='garmin',
|
||||
detailed_data=None
|
||||
)
|
||||
self.db_session.add(metric)
|
||||
processed_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing heart rate data: {str(e)}")
|
||||
failed_count += 1
|
||||
|
||||
# Process other metrics similarly...
|
||||
# For brevity, I'll show just one more example
|
||||
sleep_data = all_metrics.get('sleep_data', {})
|
||||
sleep_levels = sleep_data.get('sleep', [])
|
||||
for sleep_entry in sleep_levels:
|
||||
daily_metrics = self.garmin_client.get_daily_metrics(start_date, end_date)
|
||||
|
||||
for steps_data in daily_metrics.get("steps", []):
|
||||
try:
|
||||
metric = HealthMetric(
|
||||
metric_type='sleep',
|
||||
metric_value=sleep_entry.get('duration', 0),
|
||||
unit='minutes',
|
||||
timestamp=datetime.now(), # Actual timestamp would come from data
|
||||
date=datetime.now().date(), # Actual date would come from data
|
||||
source='garmin',
|
||||
detailed_data=sleep_entry
|
||||
)
|
||||
self.db_session.add(metric)
|
||||
self._update_or_create_metric('steps', steps_data.calendar_date, steps_data.total_steps, 'steps')
|
||||
processed_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing sleep data: {str(e)}")
|
||||
self.logger.error(f"Error processing steps data: {e}", exc_info=True)
|
||||
failed_count += 1
|
||||
|
||||
# Update sync log with results
|
||||
sync_log.status = "completed" if failed_count == 0 else "completed_with_errors"
|
||||
sync_log.end_time = datetime.now()
|
||||
for hrv_data in daily_metrics.get("hrv", []):
|
||||
try:
|
||||
self._update_or_create_metric('hrv', hrv_data.calendar_date, hrv_data.last_night_avg, 'ms')
|
||||
processed_count += 1
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error processing HRV data: {e}", exc_info=True)
|
||||
failed_count += 1
|
||||
|
||||
for sleep_data in daily_metrics.get("sleep", []):
|
||||
try:
|
||||
self._update_or_create_metric('sleep', sleep_data.daily_sleep_dto.calendar_date, sleep_data.daily_sleep_dto.sleep_time_seconds, 'seconds')
|
||||
processed_count += 1
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error processing sleep data: {e}", exc_info=True)
|
||||
failed_count += 1
|
||||
|
||||
sync_log.status = "completed_with_errors" if failed_count > 0 else "completed"
|
||||
sync_log.records_processed = processed_count
|
||||
sync_log.records_failed = failed_count
|
||||
|
||||
self.db_session.commit()
|
||||
|
||||
logger.info(f"Health metrics sync completed: {processed_count} processed, {failed_count} failed")
|
||||
return {
|
||||
"processed": processed_count,
|
||||
"failed": failed_count
|
||||
}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during health metrics sync: {str(e)}")
|
||||
|
||||
# Update sync log with error status
|
||||
self.logger.error(f"Major error during health metrics sync: {e}", exc_info=True)
|
||||
sync_log.status = "failed"
|
||||
sync_log.end_time = datetime.now()
|
||||
sync_log.message = str(e)
|
||||
|
||||
|
||||
sync_log.end_time = datetime.now()
|
||||
self.db_session.commit()
|
||||
|
||||
self.logger.info(f"=== Finished sync_health_metrics: processed={processed_count}, failed={failed_count} ===")
|
||||
return {"processed": processed_count, "failed": failed_count}
|
||||
|
||||
def _update_or_create_metric(self, metric_type: str, date: datetime.date, value: float, unit: str):
|
||||
"""Helper to update or create a health metric record."""
|
||||
try:
|
||||
existing = self.db_session.query(HealthMetric).filter_by(metric_type=metric_type, date=date).first()
|
||||
if existing:
|
||||
existing.metric_value = value
|
||||
existing.updated_at = datetime.now()
|
||||
else:
|
||||
metric = HealthMetric(
|
||||
metric_type=metric_type,
|
||||
metric_value=value,
|
||||
unit=unit,
|
||||
timestamp=datetime.combine(date, datetime.min.time()),
|
||||
date=date,
|
||||
source='garmin'
|
||||
)
|
||||
self.db_session.add(metric)
|
||||
self.db_session.commit()
|
||||
raise e
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving metric {metric_type} for {date}: {e}", exc_info=True)
|
||||
self.db_session.rollback()
|
||||
raise
|
||||
|
||||
@@ -3,21 +3,6 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
import os
|
||||
|
||||
def setup_logger(name: str, level=logging.DEBUG):
|
||||
"""Function to setup a logger that writes to the console."""
|
||||
formatter = logging.Formatter('%(asctime)s %(name)s %(levelname)s %(message)s')
|
||||
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(level)
|
||||
|
||||
if not logger.handlers:
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
return logger
|
||||
|
||||
def get_current_timestamp() -> str:
|
||||
"""Get current timestamp in ISO format."""
|
||||
return datetime.utcnow().isoformat()
|
||||
@@ -33,4 +18,4 @@ def validate_environment_vars(required_vars: list) -> bool:
|
||||
print(f"Missing required environment variables: {', '.join(missing_vars)}")
|
||||
return False
|
||||
|
||||
return True
|
||||
return True
|
||||
|
||||
28
FitnessSync/backend/src/utils/logging_config.py
Normal file
28
FitnessSync/backend/src/utils/logging_config.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import logging
|
||||
import logging.config
|
||||
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"default": {
|
||||
"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"level": "INFO",
|
||||
"formatter": "default",
|
||||
"stream": "ext://sys.stdout",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
},
|
||||
}
|
||||
|
||||
def setup_logging():
|
||||
"""Setup logging configuration."""
|
||||
logging.config.dictConfig(LOGGING_CONFIG)
|
||||
@@ -10,18 +10,20 @@
|
||||
<div class="container mt-5">
|
||||
<h1>Fitbit-Garmin Sync Dashboard</h1>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Weight Records</h5>
|
||||
<p class="card-text">Total: <span id="total-weights">0</span></p>
|
||||
<p class="card-text">Synced: <span id="synced-weights">0</span></p>
|
||||
<p class="card-text">Unsynced: <span id="unsynced-weights">0</span></p>
|
||||
</div>
|
||||
<!-- Toast container for notifications -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div id="appToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header">
|
||||
<strong class="me-auto" id="toast-title">Notification</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body" id="toast-body">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Activities</h5>
|
||||
@@ -30,13 +32,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Sync Status</h5>
|
||||
<h5 class="card-title">Sync Controls</h5>
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-primary" type="button" id="sync-weight-btn">Sync Weight</button>
|
||||
<button class="btn btn-secondary" type="button" id="sync-activities-btn">Sync Activities</button>
|
||||
<button class="btn btn-primary" type="button" id="sync-activities-btn">Sync Activities</button>
|
||||
<button class="btn btn-info" type="button" id="sync-metrics-btn">Sync Health Metrics</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,13 +55,15 @@
|
||||
<th>Operation</th>
|
||||
<th>Status</th>
|
||||
<th>Start Time</th>
|
||||
<th>Records Processed</th>
|
||||
<th>Records Failed</th>
|
||||
<th>End Time</th>
|
||||
<th>Processed</th>
|
||||
<th>Failed</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="5">Loading logs...</td>
|
||||
<td colspan="7">Loading logs...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -69,71 +73,11 @@
|
||||
|
||||
<div class="row mt-5">
|
||||
<div class="col-md-12">
|
||||
<h3>Health Metrics</h3>
|
||||
<h3>Actions</h5>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-start">
|
||||
<button class="btn btn-info me-md-2" type="button" id="sync-metrics-btn">Sync Health Metrics</button>
|
||||
<button class="btn btn-outline-info me-md-2" type="button" id="view-metrics-btn">View Health Data Summary</button>
|
||||
<button class="btn btn-outline-info" type="button" id="query-metrics-btn">Query Metrics</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-5">
|
||||
<div class="col-md-12">
|
||||
<h3>Activity Files</h3>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-start">
|
||||
<button class="btn btn-outline-secondary me-md-2" type="button" id="list-activities-btn">List Stored Activities</button>
|
||||
<button class="btn btn-outline-secondary" type="button" id="download-activities-btn">Download Activity File</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-5">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-start">
|
||||
<a href="/setup" class="btn btn-primary me-md-2">Setup & Configuration</a>
|
||||
<a href="/docs" class="btn btn-outline-secondary" target="_blank">API Documentation</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-5">
|
||||
<div class="col-md-12">
|
||||
<h3>Health Metrics</h3>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-start">
|
||||
<button class="btn btn-info me-md-2" type="button" id="sync-metrics-btn">Sync Health Metrics</button>
|
||||
<button class="btn btn-outline-info me-md-2" type="button" id="view-metrics-btn">View Health Data Summary</button>
|
||||
<button class="btn btn-outline-info" type="button" id="query-metrics-btn">Query Metrics</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-5">
|
||||
<div class="col-md-12">
|
||||
<h3>Activity Files</h3>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-start">
|
||||
<button class="btn btn-outline-secondary me-md-2" type="button" id="list-activities-btn">List Stored Activities</button>
|
||||
<button class="btn btn-outline-secondary" type="button" id="download-activities-btn">Download Activity File</button>
|
||||
</div>
|
||||
<a href="/setup" class="btn btn-primary me-md-2">Setup & Configuration</a>
|
||||
<a href="/docs" class="btn btn-outline-secondary" target="_blank">API Documentation</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,167 +86,124 @@
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
// Load dashboard data when page loads
|
||||
let toastInstance = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const toastEl = document.getElementById('appToast');
|
||||
toastInstance = new bootstrap.Toast(toastEl);
|
||||
|
||||
loadDashboardData();
|
||||
|
||||
// Set up sync buttons
|
||||
document.getElementById('sync-weight-btn').addEventListener('click', syncWeight);
|
||||
document.getElementById('sync-activities-btn').addEventListener('click', syncActivities);
|
||||
|
||||
// Set up metrics buttons
|
||||
document.getElementById('sync-metrics-btn').addEventListener('click', syncHealthMetrics);
|
||||
document.getElementById('view-metrics-btn').addEventListener('click', viewHealthSummary);
|
||||
document.getElementById('query-metrics-btn').addEventListener('click', queryMetrics);
|
||||
|
||||
// Set up activity file buttons
|
||||
document.getElementById('list-activities-btn').addEventListener('click', listActivities);
|
||||
document.getElementById('download-activities-btn').addEventListener('click', downloadActivityFile);
|
||||
});
|
||||
|
||||
function showToast(title, body, level = 'info') {
|
||||
const toastTitle = document.getElementById('toast-title');
|
||||
const toastBody = document.getElementById('toast-body');
|
||||
const toastHeader = document.querySelector('.toast-header');
|
||||
|
||||
toastTitle.textContent = title;
|
||||
toastBody.textContent = body;
|
||||
|
||||
// Reset header color
|
||||
toastHeader.classList.remove('bg-success', 'bg-danger', 'bg-warning', 'bg-info', 'text-white');
|
||||
|
||||
if (level === 'success') {
|
||||
toastHeader.classList.add('bg-success', 'text-white');
|
||||
} else if (level === 'error') {
|
||||
toastHeader.classList.add('bg-danger', 'text-white');
|
||||
} else if (level === 'warning') {
|
||||
toastHeader.classList.add('bg-warning');
|
||||
} else {
|
||||
toastHeader.classList.add('bg-info', 'text-white');
|
||||
}
|
||||
|
||||
toastInstance.show();
|
||||
}
|
||||
|
||||
async function loadDashboardData() {
|
||||
try {
|
||||
const response = await fetch('/api/status');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('total-weights').textContent = data.total_weight_records;
|
||||
document.getElementById('synced-weights').textContent = data.synced_weight_records;
|
||||
document.getElementById('unsynced-weights').textContent = data.unsynced_weight_records;
|
||||
document.getElementById('total-activities').textContent = data.total_activities;
|
||||
document.getElementById('downloaded-activities').textContent = data.downloaded_activities;
|
||||
|
||||
// Update logs table
|
||||
const logsBody = document.querySelector('#sync-logs-table tbody');
|
||||
logsBody.innerHTML = '';
|
||||
|
||||
if (data.recent_logs.length === 0) {
|
||||
logsBody.innerHTML = '<tr><td colspan="7">No recent sync logs.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
data.recent_logs.forEach(log => {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>${log.operation}</td>
|
||||
<td>${log.status}</td>
|
||||
<td>${log.start_time}</td>
|
||||
<td><span class="badge bg-${log.status === 'completed' ? 'success' : 'warning'}">${log.status}</span></td>
|
||||
<td>${new Date(log.start_time).toLocaleString()}</td>
|
||||
<td>${log.end_time ? new Date(log.end_time).toLocaleString() : 'N/A'}</td>
|
||||
<td>${log.records_processed}</td>
|
||||
<td>${log.records_failed}</td>
|
||||
<td>${log.message || ''}</td>
|
||||
`;
|
||||
logsBody.appendChild(row);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading dashboard data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncWeight() {
|
||||
try {
|
||||
const response = await fetch('/api/sync/weight', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
alert(`Weight sync initiated: ${data.message}`);
|
||||
|
||||
// Refresh dashboard data
|
||||
loadDashboardData();
|
||||
} catch (error) {
|
||||
console.error('Error syncing weight:', error);
|
||||
alert('Error initiating weight sync: ' + error.message);
|
||||
showToast('Error', 'Could not load dashboard data.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function syncActivities() {
|
||||
showToast('Syncing...', 'Activity sync has been initiated.', 'info');
|
||||
try {
|
||||
const response = await fetch('/api/sync/activities', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ days_back: 30 })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
alert(`Activity sync initiated: ${data.message}`);
|
||||
|
||||
// Refresh dashboard data
|
||||
loadDashboardData();
|
||||
showToast('Sync Complete', data.message, 'success');
|
||||
loadDashboardData(); // Refresh data after sync
|
||||
} catch (error) {
|
||||
console.error('Error syncing activities:', error);
|
||||
alert('Error initiating activity sync: ' + error.message);
|
||||
showToast('Sync Error', `Activity sync failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function syncHealthMetrics() {
|
||||
showToast('Syncing...', 'Health metrics sync has been initiated.', 'info');
|
||||
try {
|
||||
const response = await fetch('/api/sync/metrics', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
alert(`Health metrics sync initiated: ${data.message}`);
|
||||
showToast('Sync Complete', data.message, 'success');
|
||||
loadDashboardData(); // Refresh data after sync
|
||||
} catch (error) {
|
||||
console.error('Error syncing health metrics:', error);
|
||||
alert('Error initiating health metrics sync: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function viewHealthSummary() {
|
||||
try {
|
||||
const response = await fetch('/api/health-data/summary');
|
||||
const data = await response.json();
|
||||
|
||||
alert(`Health Summary:
|
||||
Steps: ${data.total_steps || 0}
|
||||
Avg Heart Rate: ${data.avg_heart_rate || 0}
|
||||
Sleep Hours: ${data.total_sleep_hours || 0}
|
||||
Avg Calories: ${data.avg_calories || 0}`);
|
||||
} catch (error) {
|
||||
console.error('Error fetching health summary:', error);
|
||||
alert('Error fetching health summary: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function queryMetrics() {
|
||||
try {
|
||||
const response = await fetch('/api/metrics/query');
|
||||
const data = await response.json();
|
||||
|
||||
alert(`Found ${data.length} health metrics`);
|
||||
} catch (error) {
|
||||
console.error('Error querying metrics:', error);
|
||||
alert('Error querying metrics: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function listActivities() {
|
||||
try {
|
||||
const response = await fetch('/api/activities/list');
|
||||
const data = await response.json();
|
||||
|
||||
alert(`Found ${data.length} stored activities`);
|
||||
} catch (error) {
|
||||
console.error('Error listing activities:', error);
|
||||
alert('Error listing activities: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadActivityFile() {
|
||||
try {
|
||||
// For demo purposes, we'll use a placeholder ID
|
||||
// In a real implementation, this would prompt for activity ID or list available activities
|
||||
const activityId = prompt('Enter activity ID to download:', '12345');
|
||||
if (activityId) {
|
||||
// This would initiate a download of the stored activity file
|
||||
window.open(`/api/activities/download/${activityId}`, '_blank');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error downloading activity file:', error);
|
||||
alert('Error downloading activity file: ' + error.message);
|
||||
showToast('Sync Error', `Health metrics sync failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Fitbit/Garmin Data Sync Implementation
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2025-12-24
|
||||
**Feature**: [Link to spec.md](./spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- The initial specification was highly technical based on the user prompt. It has been revised to focus on user-facing requirements and outcomes, making it suitable for planning.
|
||||
@@ -0,0 +1,273 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Fitbit/Garmin Sync API
|
||||
version: 1.0.0
|
||||
description: API for synchronizing and retrieving fitness data from Garmin.
|
||||
|
||||
paths:
|
||||
/api/sync/activities:
|
||||
post:
|
||||
summary: Trigger Activity Sync
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
days_back:
|
||||
type: integer
|
||||
default: 7
|
||||
responses:
|
||||
'200':
|
||||
description: Sync completed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SyncResponse'
|
||||
'400':
|
||||
description: Bad Request (e.g., Garmin not configured)
|
||||
'500':
|
||||
description: Internal Server Error
|
||||
|
||||
/api/sync/metrics:
|
||||
post:
|
||||
summary: Trigger Health Metrics Sync
|
||||
responses:
|
||||
'200':
|
||||
description: Sync completed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SyncResponse'
|
||||
'400':
|
||||
description: Bad Request (e.g., Garmin not configured)
|
||||
'500':
|
||||
description: Internal Server Error
|
||||
|
||||
/api/activities/list:
|
||||
get:
|
||||
summary: List Activities
|
||||
parameters:
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 50
|
||||
- name: offset
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
'200':
|
||||
description: A list of activities
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ActivityResponse'
|
||||
|
||||
/api/activities/query:
|
||||
get:
|
||||
summary: Query Activities
|
||||
parameters:
|
||||
- name: activity_type
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: start_date
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
- name: end_date
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
- name: download_status
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: A list of activities
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ActivityResponse'
|
||||
|
||||
/api/activities/download/{activity_id}:
|
||||
get:
|
||||
summary: Download Activity File
|
||||
parameters:
|
||||
- name: activity_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: The activity file
|
||||
content:
|
||||
application/octet-stream: {}
|
||||
'404':
|
||||
description: Activity not found or file not downloaded
|
||||
|
||||
/api/metrics/list:
|
||||
get:
|
||||
summary: List Available Metrics
|
||||
responses:
|
||||
'200':
|
||||
description: A list of available metric types and date ranges
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MetricsListResponse'
|
||||
|
||||
/api/metrics/query:
|
||||
get:
|
||||
summary: Query Health Metrics
|
||||
parameters:
|
||||
- name: metric_type
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: start_date
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
- name: end_date
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 100
|
||||
responses:
|
||||
'200':
|
||||
description: A list of health metrics
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/HealthMetricResponse'
|
||||
|
||||
/api/health-data/summary:
|
||||
get:
|
||||
summary: Get Health Data Summary
|
||||
parameters:
|
||||
- name: start_date
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
- name: end_date
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
responses:
|
||||
'200':
|
||||
description: A summary of health data
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HealthDataSummary'
|
||||
|
||||
components:
|
||||
schemas:
|
||||
SyncResponse:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
job_id:
|
||||
type: string
|
||||
|
||||
ActivityResponse:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
garmin_activity_id:
|
||||
type: string
|
||||
activity_name:
|
||||
type: string
|
||||
activity_type:
|
||||
type: string
|
||||
start_time:
|
||||
type: string
|
||||
format: date-time
|
||||
duration:
|
||||
type: integer
|
||||
file_type:
|
||||
type: string
|
||||
download_status:
|
||||
type: string
|
||||
downloaded_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
HealthMetricResponse:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
metric_type:
|
||||
type: string
|
||||
metric_value:
|
||||
type: number
|
||||
unit:
|
||||
type: string
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
date:
|
||||
type: string
|
||||
format: date
|
||||
source:
|
||||
type: string
|
||||
detailed_data:
|
||||
type: object
|
||||
nullable: true
|
||||
|
||||
MetricsListResponse:
|
||||
type: object
|
||||
properties:
|
||||
metric_types:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
date_range:
|
||||
type: object
|
||||
properties:
|
||||
start_date:
|
||||
type: string
|
||||
format: date
|
||||
end_date:
|
||||
type: string
|
||||
format: date
|
||||
|
||||
HealthDataSummary:
|
||||
type: object
|
||||
properties:
|
||||
total_steps:
|
||||
type: integer
|
||||
avg_heart_rate:
|
||||
type: number
|
||||
total_sleep_hours:
|
||||
type: number
|
||||
avg_calories:
|
||||
type: number
|
||||
65
FitnessSync/specs/002-fitbit-garmin-sync/data-model.md
Normal file
65
FitnessSync/specs/002-fitbit-garmin-sync/data-model.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Data Model: Fitbit/Garmin Data Sync
|
||||
|
||||
**Date**: 2025-12-24
|
||||
|
||||
This document describes the data models for the entities involved in the Fitbit/Garmin data sync feature. The models are based on the existing SQLAlchemy models in the project.
|
||||
|
||||
## Activity
|
||||
|
||||
Represents a single fitness activity (e.g., running, cycling).
|
||||
|
||||
**SQLAlchemy Model**: `src/models/activity.py`
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------ | ------------ | ----------------------------------------------------------- |
|
||||
| `id` | Integer | Primary key. |
|
||||
| `garmin_activity_id` | String | The original unique identifier from Garmin Connect. |
|
||||
| `activity_name` | String | The name of the activity (e.g., "Afternoon Run"). |
|
||||
| `activity_type` | String | The type of activity (e.g., 'running', 'cycling'). |
|
||||
| `start_time` | DateTime | The time the activity started. |
|
||||
| `duration` | Integer | The duration of the activity in seconds. |
|
||||
| `file_content` | LargeBinary | The original activity file content (e.g., .fit, .gpx, .tcx).|
|
||||
| `file_type` | String | The file type of the original activity file. |
|
||||
| `download_status` | String | The status of the file download ('pending', 'downloaded', 'failed'). |
|
||||
| `downloaded_at` | DateTime | The timestamp when the file was downloaded. |
|
||||
| `created_at` | DateTime | The timestamp when the record was created. |
|
||||
| `updated_at` | DateTime | The timestamp when the record was last updated. |
|
||||
|
||||
## HealthMetric
|
||||
|
||||
Represents a single health data point (e.g., steps, heart rate variability).
|
||||
|
||||
**SQLAlchemy Model**: `src/models/health_metric.py`
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------- | -------- | ----------------------------------------------------- |
|
||||
| `id` | Integer | Primary key. |
|
||||
| `metric_type` | String | The type of metric (e.g., 'steps', 'heart_rate'). |
|
||||
| `metric_value` | Float | The value of the metric. |
|
||||
| `unit` | String | The unit of measurement (e.g., 'steps', 'ms'). |
|
||||
| `timestamp` | DateTime | The timestamp when the metric was recorded. |
|
||||
| `date` | DateTime | The date of the metric. |
|
||||
| `source` | String | The source of the metric (e.g., 'garmin'). |
|
||||
| `detailed_data`| Text | Additional details, stored as a JSON string. |
|
||||
| `created_at` | DateTime | The timestamp when the record was created. |
|
||||
| `updated_at` | DateTime | The timestamp when the record was last updated. |
|
||||
|
||||
## SyncLog
|
||||
|
||||
Records the status and results of each synchronization operation.
|
||||
|
||||
**SQLAlchemy Model**: `src/models/sync_log.py`
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------- | -------- | -------------------------------------------------------------------- |
|
||||
| `id` | Integer | Primary key. |
|
||||
| `operation` | String | The type of operation (e.g., 'metrics_download', 'activity_sync'). |
|
||||
| `status` | String | The status of the operation ('started', 'completed', 'failed'). |
|
||||
| `message` | Text | A status message or error details. |
|
||||
| `start_time` | DateTime | The timestamp when the operation started. |
|
||||
| `end_time` | DateTime | The timestamp when the operation completed. |
|
||||
| `records_processed` | Integer | The number of records successfully processed. |
|
||||
- `records_failed` | Integer | The number of records that failed to process. |
|
||||
| `user_id` | Integer | A reference to the user for whom the sync was run (if applicable). |
|
||||
| `created_at` | DateTime | The timestamp when the record was created. |
|
||||
| `updated_at` | DateTime | The timestamp when the record was last updated. |
|
||||
77
FitnessSync/specs/002-fitbit-garmin-sync/plan.md
Normal file
77
FitnessSync/specs/002-fitbit-garmin-sync/plan.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Implementation Plan: Fitbit/Garmin Data Sync
|
||||
|
||||
**Branch**: `002-fitbit-garmin-sync` | **Date**: 2025-12-24 | **Spec**: [link](./spec.md)
|
||||
**Input**: Feature specification from `specs/002-fitbit-garmin-sync/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
This feature will implement synchronization of activities and health metrics from Garmin Connect to the application's database. The implementation will involve wiring up existing backend stub endpoints to a sync service that uses the `garth` library to fetch data from Garmin. The synced data will be stored in a PostgreSQL database and exposed through a series of new API endpoints for querying and retrieval.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.11
|
||||
**Primary Dependencies**: FastAPI, Uvicorn, SQLAlchemy, Pydantic, garth
|
||||
**Storage**: PostgreSQL
|
||||
**Testing**: pytest, pytest-asyncio
|
||||
**Target Platform**: Linux server (via Docker)
|
||||
**Project Type**: Web application (backend)
|
||||
**Performance Goals**: Standard web application performance, with API responses under 500ms p95.
|
||||
**Constraints**: The solution should be implemented within the existing `backend` service.
|
||||
**Scale/Scope**: The initial implementation will support syncing data for a single user.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- **Test-First**: The implementation will follow a test-driven approach. For each endpoint and service function, a corresponding set of unit and/or integration tests will be written before the implementation.
|
||||
|
||||
*VERDICT: The plan adheres to the core principles of the constitution.*
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/002-fitbit-garmin-sync/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
├── contracts/ # Phase 1 output
|
||||
│ └── api-contract.yaml
|
||||
└── tasks.md # Phase 2 output (created by /speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
The implementation will be contained within the existing `backend` directory structure.
|
||||
|
||||
```text
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── api/
|
||||
│ │ ├── activities.py
|
||||
│ │ ├── metrics.py
|
||||
│ │ └── sync.py
|
||||
│ ├── models/
|
||||
│ │ ├── activity.py
|
||||
│ │ └── health_metric.py
|
||||
│ └── services/
|
||||
│ ├── sync_app.py
|
||||
│ └── garmin/
|
||||
│ └── client.py
|
||||
└── tests/
|
||||
├── integration/
|
||||
│ ├── test_sync_flow.py
|
||||
└── unit/
|
||||
├── test_api/
|
||||
│ ├── test_activities.py
|
||||
│ └── test_metrics.py
|
||||
└── test_services/
|
||||
└── test_sync_app.py
|
||||
```
|
||||
|
||||
**Structure Decision**: The plan adheres to the existing project structure, which is a single backend service. New functionality will be added to the appropriate existing modules.
|
||||
|
||||
## Complexity Tracking
|
||||
*No constitutional violations to justify.*
|
||||
78
FitnessSync/specs/002-fitbit-garmin-sync/quickstart.md
Normal file
78
FitnessSync/specs/002-fitbit-garmin-sync/quickstart.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Quickstart: Fitbit/Garmin Data Sync
|
||||
|
||||
**Date**: 2025-12-24
|
||||
|
||||
This guide provides instructions on how to set up and run the application to test the Fitbit/Garmin data sync feature.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose
|
||||
- Python 3.11
|
||||
- An active Garmin Connect account
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Clone the repository**:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd <repository-name>
|
||||
```
|
||||
|
||||
2. **Set up environment variables**:
|
||||
- Copy the `.env.example` file to `.env`.
|
||||
- Fill in the required environment variables, including your Garmin Connect email and password.
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your credentials
|
||||
```
|
||||
|
||||
3. **Build and run the application**:
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
The application will be available at `http://localhost:8000`.
|
||||
|
||||
## Testing the Endpoints
|
||||
|
||||
You can use `curl` or any API client to test the new endpoints.
|
||||
|
||||
### 1. Trigger Activity Sync
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/sync/activities \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"days_back": 7}'
|
||||
```
|
||||
|
||||
### 2. List Activities
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/activities/list
|
||||
```
|
||||
|
||||
### 3. Trigger Health Metrics Sync
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/sync/metrics
|
||||
```
|
||||
|
||||
### 4. Query Health Metrics
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8000/api/metrics/query?metric_type=steps&limit=10"
|
||||
```
|
||||
|
||||
### 5. Verify Data in Database
|
||||
|
||||
You can connect to the PostgreSQL database to verify that the data has been synced correctly.
|
||||
|
||||
```bash
|
||||
docker exec -it <postgres-container-name> psql -U postgres -d fitbit_garmin_sync
|
||||
```
|
||||
|
||||
Then, run SQL queries to inspect the `activities` and `health_metrics` tables:
|
||||
|
||||
```sql
|
||||
SELECT * FROM activities;
|
||||
SELECT * FROM health_metrics;
|
||||
```
|
||||
30
FitnessSync/specs/002-fitbit-garmin-sync/research.md
Normal file
30
FitnessSync/specs/002-fitbit-garmin-sync/research.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Research: Fitbit/Garmin Data Sync
|
||||
|
||||
**Date**: 2025-12-24
|
||||
|
||||
This document outlines the research performed for the Fitbit/Garmin data sync feature.
|
||||
|
||||
## `garth` Library for Garmin Communication
|
||||
|
||||
**Decision**: The `garth` library will be used for all communication with the Garmin Connect API.
|
||||
|
||||
**Rationale**:
|
||||
- The user's initial technical specification explicitly mentioned and provided examples using `garth`.
|
||||
- The library appears to be actively maintained and provides the necessary functionality for fetching activities and health metrics.
|
||||
- It handles the authentication flow with Garmin, which is a complex part of the integration.
|
||||
|
||||
**Alternatives considered**:
|
||||
- `garminconnect`: The user's `requirements.txt` includes this library, but the technical details provided in the prompt favored `garth`. `garth` seems to be a more modern library for interacting with the Garmin API.
|
||||
|
||||
**Key Findings from User Prompt & `garth` Documentation:**
|
||||
- The user's prompt provided detailed code snippets for using `garth` to fetch `DailySteps` and `DailyHRV`. This will serve as a strong starting point for the implementation.
|
||||
- The `garth` library offers a variety of other metric types that can be explored for future enhancements (e.g., stress, sleep).
|
||||
- Authentication is handled by the `garth.client.Garth` class, which needs to be configured with the user's credentials. The application already has a mechanism for storing API tokens, which will be used to store the Garmin credentials.
|
||||
|
||||
## API Endpoint Design
|
||||
|
||||
**Decision**: The API endpoints will be implemented as described in the user's initial technical specification.
|
||||
|
||||
**Rationale**: The user provided a complete and well-defined set of API endpoints, including request/response models and URL paths. This design is consistent with a standard RESTful API and meets all the requirements of the feature.
|
||||
|
||||
**Alternatives considered**: None. The user's specification was clear and complete.
|
||||
157
FitnessSync/specs/002-fitbit-garmin-sync/spec.md
Normal file
157
FitnessSync/specs/002-fitbit-garmin-sync/spec.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Feature Specification: Fitbit/Garmin Data Sync
|
||||
|
||||
**Feature Branch**: `002-fitbit-garmin-sync`
|
||||
**Created**: 2025-12-24
|
||||
**Status**: Implemented
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Sync Activities from Garmin (Priority: P1)
|
||||
|
||||
As a user, I want to trigger a synchronization of my recent activities from my Garmin account so that my fitness data is stored and available within the application.
|
||||
|
||||
**Why this priority**: This is a core feature, enabling users to bring their primary workout data into the system.
|
||||
|
||||
**Independent Test**: This can be tested by a user triggering a sync and verifying their recent activities appear in their activity list.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** I have connected my Garmin account, **When** I trigger an activity sync, **Then** my activities from the last 30 days appear in my activity list.
|
||||
2. **Given** my Garmin account is not connected, **When** I attempt to trigger a sync, **Then** I see a message instructing me to connect my account first.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - View and Query Activities (Priority: P2)
|
||||
|
||||
As a user, I want to list my synced activities, filter them by criteria like activity type and date, and download the original activity file for my records.
|
||||
|
||||
**Why this priority**: Allows users to find, analyze, and export their workout data.
|
||||
|
||||
**Independent Test**: A user can navigate to the activity list, apply filters, and attempt to download a file.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** I have synced activities, **When** I view the activity list, **Then** I see a paginated list of my activities, sorted by most recent first.
|
||||
2. **Given** I have a list of activities, **When** I filter by "running" and a specific date range, **Then** I only see running activities within that range.
|
||||
3. **Given** an activity has its original file available, **When** I click "Download", **Then** the file is downloaded to my device.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Sync Health Metrics from Garmin (Priority: P1)
|
||||
|
||||
As a user, I want to trigger a synchronization of my daily health metrics (like steps and HRV) from my Garmin account to track my wellness over time.
|
||||
|
||||
**Why this priority**: Provides users with a holistic view of their health beyond just workouts.
|
||||
|
||||
**Independent Test**: A user can trigger a metric sync and see updated data in their health dashboard or metric query views.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** I have connected my Garmin account, **When** I trigger a health metric sync, **Then** my metrics from the last 30 days are updated.
|
||||
2. **Given** data is synced, **When** I view my dashboard, **Then** I see my latest step count and HRV data.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - View and Query Health Metrics (Priority: P2)
|
||||
|
||||
As a user, I want to see a list of all the metric types I've synced, query my data for specific time ranges, and view a high-level summary.
|
||||
|
||||
**Why this priority**: Enables users to analyze trends and understand their health data.
|
||||
|
||||
**Independent Test**: A user can navigate to the metrics section, select a metric type and date range, and view the resulting data and summary.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** I have synced health metrics, **When** I visit the metrics page, **Then** I see a list of all available metric types (e.g., "Steps", "HRV").
|
||||
2. **Given** I have synced step data, **When** I query for "Steps" in the last week, **Then** I see a chart or list of my daily step counts for that period.
|
||||
3. **Given** I have synced data, **When** I view the health summary for the last month, **Then** I see accurate totals and averages for my key metrics.
|
||||
|
||||
### Edge Cases
|
||||
- What happens if the Garmin service is unavailable during a sync?
|
||||
- How does the system handle a user revoking access from the Garmin side?
|
||||
- What should be displayed for a day with no data for a specific metric?
|
||||
- How does the system handle a sync that is interrupted mid-way?
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### UI/UX Improvements
|
||||
- **UI-001**: The dashboard (`/`) MUST display current sync statistics (total activities, downloaded activities) and recent sync logs in a user-friendly table.
|
||||
- **UI-002**: All user interactions (e.g., triggering syncs, fetching data) MUST provide feedback via non-blocking toast notifications instead of disruptive `alert()` popups.
|
||||
- **UI-003**: The dashboard MUST provide clear buttons to trigger activity sync, health metrics sync, and navigate to setup/configuration and API documentation.
|
||||
- **UI-004**: The dashboard MUST clearly display a link to the `/setup` page for authentication.
|
||||
|
||||
### Authentication and Setup
|
||||
|
||||
- **AS-001**: The system MUST provide an endpoint (`/setup/auth-status`) to check the current authentication status for Garmin. (Fitbit status is a placeholder).
|
||||
- **AS-002**: Users MUST be able to initiate a connection to their Garmin account by providing their username and password via a `POST` to `/setup/garmin`. The system automatically configures the correct Garmin domain (`garmin.com` or `garmin.cn`) based on the `is_china` flag.
|
||||
- **AS-003**: The system MUST handle Garmin's Multi-Factor Authentication (MFA) process. If MFA is required, the `/setup/garmin` endpoint will return `mfa_required` status, and the user must complete the process by sending the verification code to `/setup/garmin/mfa`.
|
||||
- **AS-004**: The system MUST securely store the resulting `garth` authentication tokens (OAuth1 and OAuth2) and serializable MFA state in the database for future use, associated with the 'garmin' token type.
|
||||
- **AS-005**: The system MUST provide an endpoint (`/setup/garmin/test-token`) to validate the stored Garmin authentication tokens by attempting to fetch user profile information via `garth.UserProfile.get()`.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: Users MUST be able to trigger a manual synchronization of their Garmin activities via a `POST` request to the `/api/sync/activities` endpoint. This endpoint accepts a `days_back` parameter to specify the sync range.
|
||||
- **FR-002**: The system MUST fetch activity metadata from Garmin using `garth.client.connectapi("/activitylist-service/activities/search/activities", ...)` and store users' Garmin activities in the database, including `garmin_activity_id`, `activity_name`, `activity_type` (extracted from `activityType.typeKey`), `start_time`, `duration`, `file_type`, `download_status`, `downloaded_at`, and the binary `file_content`.
|
||||
- **FR-003**: When downloading activity files, the system MUST attempt to download in preferred formats (`original`, `tcx`, `gpx`, `fit`) sequentially until a successful download is achieved. The `garth.client.download()` method MUST be used with a dynamically constructed path like `/download-service/export/{file_type}/activity/{activity_id}`.
|
||||
- **FR-004**: Users MUST be able to view a paginated list of their synchronized activities via a `GET` request to the `/api/activities/list` endpoint.
|
||||
- **FR-005**: Users MUST be able to filter their activities by `activity_type`, `start_date`, `end_date`, and `download_status` via a `GET` request to the `/api/activities/query` endpoint.
|
||||
- **FR-006**: Users MUST be able to download the original data file for an individual activity via a `GET` request to the `/api/activities/download/{activity_id}` endpoint.
|
||||
- **FR-007**: Users MUST be able to trigger a manual synchronization of their Garmin health metrics via a `POST` request to the `/api/sync/metrics` endpoint. This endpoint accepts a `days_back` parameter.
|
||||
- **FR-008**: The system MUST fetch daily health metrics (steps, HRV, sleep) using `garth.stats.steps.DailySteps.list()`, `garth.stats.hrv.DailyHRV.list()`, and `garth.data.sleep.SleepData.list()` respectively.
|
||||
- **FR-009**: The system MUST store users' Garmin health metrics in the database, including `metric_type` ('steps', 'hrv', 'sleep'), `metric_value`, `unit`, `timestamp`, `date`, and `source` ('garmin').
|
||||
- **FR-010**: Users MUST be able to see which types of health metrics are available for querying via a `GET` request to the `/api/metrics/list` endpoint.
|
||||
- **FR-011**: Users MUST be able to query their health metrics by `metric_type`, `start_date`, and `end_date` via a `GET` request to the `/api/metrics/query` endpoint.
|
||||
- **FR-012**: Users MUST be able to view a summary of their health data (e.g., totals, averages) over a specified time period via a `GET` request to the `/api/health-data/summary` endpoint.
|
||||
- **FR-013**: The system MUST provide clear feedback on the status of a synchronization. This includes a summary of sync status available via the `/api/status` endpoint and a detailed list of synchronization logs available via the `/api/logs` endpoint.
|
||||
- **FR-014**: The system MUST handle synchronization failures gracefully and log errors for troubleshooting.
|
||||
- **FR-015**: Users MUST be able to trigger a manual synchronization of their weight data via a `POST` request to the `/api/sync/weight` endpoint. (Note: Actual implementation for weight sync is currently a placeholder).
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Activity**: Represents a single fitness activity.
|
||||
- `id`: Unique identifier in the database.
|
||||
- `garmin_activity_id`: The ID from Garmin.
|
||||
- `activity_name`: User-defined name of the activity.
|
||||
- `activity_type`: Type of activity (e.g., 'running', 'cycling'), extracted from `activityType.typeKey`.
|
||||
- `start_time`: The start time of the activity.
|
||||
- `duration`: Duration of the activity in seconds.
|
||||
- `file_type`: The format of the downloaded file ('tcx', 'gpx', 'fit', or 'original').
|
||||
- `file_content`: The binary content of the activity file.
|
||||
- `download_status`: The status of the file download from Garmin ('pending', 'downloaded', 'failed').
|
||||
- `downloaded_at`: Timestamp when the file was downloaded.
|
||||
- **Health Metric**: Represents a single health data point for a specific day.
|
||||
- `id`: Unique identifier in the database.
|
||||
- `metric_type`: The type of metric (e.g., 'steps', 'hrv', 'sleep').
|
||||
- `metric_value`: The value of the metric.
|
||||
- `unit`: The unit of measurement.
|
||||
- `timestamp`: The precise timestamp of the metric.
|
||||
- `date`: The date the metric was recorded.
|
||||
- `source`: The source of the data (e.g., 'garmin').
|
||||
- **Sync Log**: Records the outcome of each synchronization attempt.
|
||||
- `id`: Unique identifier in the database.
|
||||
- `operation`: The type of sync operation ('activity_sync', 'health_metric_sync', 'weight_sync').
|
||||
- `status`: The outcome ('started', 'completed', 'completed_with_errors', 'failed').
|
||||
- `message`: A descriptive message about the outcome.
|
||||
- `start_time`: When the sync began.
|
||||
- `end_time`: When the sync completed.
|
||||
- `records_processed`: Number of records successfully processed.
|
||||
- `records_failed`: Number of records that failed.
|
||||
- **APIToken**: Stores authentication tokens for third-party services.
|
||||
- `id`: Unique identifier in the database.
|
||||
- `token_type`: The service the token is for ('garmin', 'fitbit').
|
||||
- `garth_oauth1_token`: The OAuth1 token for Garmin (JSON string).
|
||||
- `garth_oauth2_token`: The OAuth2 token for Garmin (JSON string).
|
||||
- `mfa_state`: Stores serializable MFA state (JSON string) if MFA is required during login.
|
||||
- `mfa_expires_at`: Timestamp indicating when the MFA state expires.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: 95% of users can successfully sync their last 30 days of activities from Garmin on the first attempt.
|
||||
- **SC-002**: 100% of successfully synced activities appear in the user's activity list within 1 minute of sync completion.
|
||||
- **SC-003**: 99% of activity list filtering operations return accurate results in under 2 seconds.
|
||||
- **SC-004**: Users can successfully download the original data file for any activity where one is present.
|
||||
- **SC-005**: 95% of users can successfully sync their last 30 days of health metrics from Garmin, including steps and HRV.
|
||||
- **SC-006**: The health data summary for a 30-day period is calculated and displayed in under 3 seconds.
|
||||
- **SC-007**: The system provides a user-friendly error message within 10 seconds if a Garmin sync fails due to authentication or service availability issues.
|
||||
215
FitnessSync/specs/002-fitbit-garmin-sync/tasks.md
Normal file
215
FitnessSync/specs/002-fitbit-garmin-sync/tasks.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# Tasks: Fitbit/Garmin Data Sync
|
||||
|
||||
**Input**: Design documents from `specs/002-fitbit-garmin-sync/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||
|
||||
**Tests**: Tests are included based on the TDD principle mentioned in the constitution.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
## Path Conventions
|
||||
|
||||
- Paths shown below assume single project - adjust based on plan.md structure
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Project initialization and basic structure
|
||||
|
||||
- [ ] T001 Review `sync_app.py` in `backend/src/services/sync_app.py`
|
||||
- [ ] T002 Review `garmin/client.py` in `backend/src/services/garmin/client.py`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
- [ ] T003 Add error handling to all endpoints in `backend/src/api/`
|
||||
- [ ] T004 Add logging at key points in `backend/src/api/` and `backend/src/services/`
|
||||
- [ ] T005 Add missing imports for API Token, SyncApp, GarminClient, setup_logger to `backend/src/api/sync.py`
|
||||
- [ ] T006 Add missing imports for Response, Activity to `backend/src/api/activities.py`
|
||||
- [ ] T007 Add missing imports for func, HealthMetric to `backend/src/api/metrics.py`
|
||||
|
||||
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Sync Activities from Garmin (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Users can trigger a synchronization of their recent activities from their Garmin account so that their fitness data is stored and available within the application.
|
||||
|
||||
**Independent Test**: Trigger a sync and verify recent activities appear in the activity list. Also, verify error message when Garmin account is not connected.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [ ] T008 [US1] Write unit tests for `POST /api/sync/activities` endpoint in `backend/tests/unit/test_api/test_sync.py`
|
||||
- [ ] T009 [US1] Write integration tests for activity sync flow in `backend/tests/integration/test_sync_flow.py`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [ ] T010 [US1] Implement activity sync logic in `backend/src/api/sync.py` (replace mock response with actual sync logic)
|
||||
- [ ] T011 [US1] Implement `sync_activities` method in `backend/src/services/sync_app.py` to use `GarminClient`
|
||||
|
||||
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 3 - Sync Health Metrics from Garmin (Priority: P1)
|
||||
|
||||
**Goal**: Users can trigger a synchronization of their daily health metrics (like steps and HRV) from their Garmin account to track their wellness over time.
|
||||
|
||||
**Independent Test**: Trigger a metric sync and see updated data in their health dashboard or metric query views.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [ ] T012 [US3] Write unit tests for `POST /api/sync/metrics` endpoint in `backend/tests/unit/test_api/test_sync.py`
|
||||
- [ ] T013 [US3] Write integration tests for health metrics sync flow in `backend/tests/integration/test_sync_flow.py`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [ ] T014 [US3] Create `POST /api/sync/metrics` endpoint in `backend/src/api/sync.py`
|
||||
- [ ] T015 [US3] Implement `sync_health_metrics` method in `backend/src/services/sync_app.py` to use `garth` library
|
||||
|
||||
**Checkpoint**: At this point, User Stories 1 AND 3 should both work independently
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 2 - View and Query Activities (Priority: P2)
|
||||
|
||||
**Goal**: Users can list their synced activities, filter them by criteria like activity type and date, and download the original activity file for their records.
|
||||
|
||||
**Independent Test**: Navigate to the activity list, apply filters, and attempt to download a file.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [ ] T016 [US2] Write unit tests for `GET /api/activities/list` endpoint in `backend/tests/unit/test_api/test_activities.py`
|
||||
- [ ] T017 [US2] Write unit tests for `GET /api/activities/query` endpoint in `backend/tests/unit/test_api/test_activities.py`
|
||||
- [ ] T018 [US2] Write unit tests for `GET /api/activities/download/{activity_id}` endpoint in `backend/tests/unit/test_api/test_activities.py`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [ ] T019 [US2] Implement `list_activities` endpoint logic in `backend/src/api/activities.py`
|
||||
- [ ] T020 [US2] Implement `query_activities` endpoint logic in `backend/src/api/activities.py`
|
||||
- [ ] T021 [US2] Implement `download_activity` endpoint logic in `backend/src/api/activities.py`
|
||||
|
||||
**Checkpoint**: At this point, User Stories 1, 3 and 2 should all work independently
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - View and Query Health Metrics (Priority: P2)
|
||||
|
||||
**Goal**: Users can see a list of all the metric types they've synced, query their data for specific time ranges, and view a high-level summary.
|
||||
|
||||
**Independent Test**: Navigate to the metrics section, select a metric type and date range, and view the resulting data and summary.
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [ ] T022 [US4] Write unit tests for `GET /api/metrics/list` endpoint in `backend/tests/unit/test_api/test_metrics.py`
|
||||
- [ ] T023 [US4] Write unit tests for `GET /api/metrics/query` endpoint in `backend/tests/unit/test_api/test_metrics.py`
|
||||
- [ ] T024 [US4] Write unit tests for `GET /api/health-data/summary` endpoint in `backend/tests/unit/test_api/test_metrics.py`
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [ ] T025 [US4] Implement `list_available_metrics` endpoint logic in `backend/src/api/metrics.py`
|
||||
- [ ] T026 [US4] Implement `query_metrics` endpoint logic in `backend/src/api/metrics.py`
|
||||
- [ ] T027 [US4] Implement `get_health_summary` endpoint logic in `backend/src/api/metrics.py`
|
||||
|
||||
**Checkpoint**: All user stories should now be independently functional
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Improvements that affect multiple user stories
|
||||
|
||||
- [ ] T028 Review error handling across all new endpoints in `backend/src/api/`
|
||||
- [ ] T029 Review logging implementation across `backend/src/api/` and `backend/src/services/`
|
||||
- [ ] T030 Add/update documentation for new API endpoints (e.g., OpenAPI spec, inline comments) in `specs/002-fitbit-garmin-sync/contracts/api-contract.yaml` and relevant Python files.
|
||||
- [ ] T031 Run quickstart.md validation as described in `specs/002-fitbit-garmin-sync/quickstart.md`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies - can start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
|
||||
- **User Stories (Phase 3+)**: All depend on Foundational phase completion
|
||||
- User stories can then proceed in parallel (if staffed)
|
||||
- Or sequentially in priority order (P1 → P1 → P2 → P2)
|
||||
- **Polish (Final Phase)**: Depends on all desired user stories being complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories
|
||||
- **User Story 3 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories
|
||||
- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable
|
||||
- **User Story 4 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1/US3/US2 but should be independently testable
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests MUST be written and FAIL before implementation
|
||||
- Models before services (where applicable)
|
||||
- Services before endpoints (where applicable)
|
||||
- Core implementation before integration
|
||||
- Story complete before moving to next priority
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- All Setup tasks can run in parallel.
|
||||
- All Foundational tasks can run in parallel (within Phase 2).
|
||||
- Once Foundational phase completes, user stories can start in parallel by different team members.
|
||||
- Tests for a user story can run in parallel.
|
||||
- Implementation tasks within a user story can be identified for parallel execution where there are no dependencies.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Stories 1 & 3)
|
||||
|
||||
1. Complete Phase 1: Setup
|
||||
2. Complete Phase 2: Foundational (CRITICAL - blocks all stories)
|
||||
3. Complete Phase 3: User Story 1
|
||||
4. Complete Phase 4: User Story 3
|
||||
5. **STOP and VALIDATE**: Test User Stories 1 and 3 independently.
|
||||
6. Deploy/demo if ready
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Setup + Foundational → Foundation ready
|
||||
2. Add User Story 1 → Test independently → Deploy/Demo (MVP!)
|
||||
3. Add User Story 3 → Test independently → Deploy/Demo
|
||||
4. Add User Story 2 → Test independently → Deploy/Demo
|
||||
5. Add User Story 4 → Test independently → Deploy/Demo
|
||||
6. Each story adds value without breaking previous stories
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
With multiple developers:
|
||||
|
||||
1. Team completes Setup + Foundational together
|
||||
2. Once Foundational is done:
|
||||
- Developer A: User Story 1
|
||||
- Developer B: User Story 3
|
||||
- Developer C: User Story 2
|
||||
- Developer D: User Story 4
|
||||
3. Stories complete and integrate independently
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks with file paths indicate specific files to be created or modified.
|
||||
- Each user story should be independently completable and testable.
|
||||
- Verify tests fail before implementing.
|
||||
- Commit after each task or logical group.
|
||||
- Stop at any checkpoint to validate story independently.
|
||||
- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence
|
||||
Reference in New Issue
Block a user