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.templating import Jinja2Templates
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from contextlib import asynccontextmanager
|
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.config import Config
|
||||||
from alembic import command
|
from alembic import command
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
# Create application lifespan to handle startup/shutdown
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup
|
# Startup
|
||||||
# Run database migrations
|
setup_logging()
|
||||||
alembic_cfg = Config("alembic.ini")
|
logger = logging.getLogger(__name__)
|
||||||
database_url = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/fitbit_garmin_sync")
|
logger.info("--- Application Starting Up ---")
|
||||||
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
|
|
||||||
command.upgrade(alembic_cfg, "head")
|
|
||||||
|
|
||||||
# Initialize database tables
|
alembic_cfg = Config("alembic.ini")
|
||||||
db_manager = PostgreSQLManager(database_url=database_url)
|
database_url = os.getenv("DATABASE_URL")
|
||||||
db_manager.init_db()
|
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
|
yield
|
||||||
|
|
||||||
# Shutdown
|
logger.info("--- Application Shutting Down ---")
|
||||||
# Add any cleanup code here if needed
|
|
||||||
|
|
||||||
# Create FastAPI app with lifespan
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
# Mount static files
|
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
# Initialize templates
|
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
# Include API routes
|
from src.api import status, sync, setup, logs, metrics, activities
|
||||||
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
|
|
||||||
|
|
||||||
app.include_router(status_router, prefix="/api")
|
app.include_router(status.router, prefix="/api")
|
||||||
app.include_router(sync_router, prefix="/api")
|
app.include_router(sync.router, prefix="/api")
|
||||||
app.include_router(setup_router, prefix="/api")
|
app.include_router(setup.router, prefix="/api")
|
||||||
app.include_router(logs_router, prefix="/api")
|
app.include_router(logs.router, prefix="/api")
|
||||||
app.include_router(metrics_router, prefix="/api")
|
app.include_router(metrics.router, prefix="/api")
|
||||||
app.include_router(activities_router, prefix="/api")
|
app.include_router(activities.router, prefix="/api")
|
||||||
|
|
||||||
from fastapi import Request
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def read_root(request: Request):
|
async def read_root(request: Request):
|
||||||
@@ -58,4 +50,4 @@ async def read_root(request: Request):
|
|||||||
|
|
||||||
@app.get("/setup")
|
@app.get("/setup")
|
||||||
async def setup_page(request: Request):
|
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 pydantic import BaseModel
|
||||||
from typing import List, Optional, Dict, Any
|
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()
|
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):
|
class ActivityResponse(BaseModel):
|
||||||
id: Optional[int] = None
|
id: Optional[int] = None
|
||||||
garmin_activity_id: Optional[str] = None
|
garmin_activity_id: Optional[str] = None
|
||||||
@@ -19,26 +32,143 @@ class ActivityResponse(BaseModel):
|
|||||||
@router.get("/activities/list", response_model=List[ActivityResponse])
|
@router.get("/activities/list", response_model=List[ActivityResponse])
|
||||||
async def list_activities(
|
async def list_activities(
|
||||||
limit: int = Query(50, ge=1, le=200),
|
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 metadata for all downloaded/available activities.
|
||||||
return []
|
"""
|
||||||
|
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])
|
@router.get("/activities/query", response_model=List[ActivityResponse])
|
||||||
async def query_activities(
|
async def query_activities(
|
||||||
activity_type: Optional[str] = Query(None),
|
activity_type: Optional[str] = Query(None),
|
||||||
start_date: Optional[str] = Query(None),
|
start_date: Optional[str] = Query(None),
|
||||||
end_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
|
Allow advanced filtering of activities.
|
||||||
return []
|
"""
|
||||||
|
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}")
|
@router.get("/activities/download/{activity_id}")
|
||||||
async def download_activity(activity_id: str):
|
async def download_activity(activity_id: str, db: Session = Depends(get_db)):
|
||||||
# This would serve the stored activity file from the database
|
"""
|
||||||
# Implementation will connect with the services layer
|
Serve the stored activity file from the database.
|
||||||
# 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"})
|
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 pydantic import BaseModel
|
||||||
from typing import List, Optional, Dict, Any
|
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()
|
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):
|
class HealthMetricResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
metric_type: str
|
metric_type: str
|
||||||
@@ -28,71 +41,188 @@ class HealthDataSummary(BaseModel):
|
|||||||
total_sleep_hours: Optional[float] = 0.0
|
total_sleep_hours: Optional[float] = 0.0
|
||||||
avg_calories: 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)
|
@router.get("/metrics/list", response_model=MetricsListResponse)
|
||||||
async def list_available_metrics():
|
async def list_available_metrics(db: Session = Depends(get_db)):
|
||||||
# This would return available metric types and date ranges
|
"""
|
||||||
# Implementation will connect with the services layer
|
Return available metric types and date ranges.
|
||||||
return {
|
"""
|
||||||
"metric_types": ["steps", "heart_rate", "sleep", "calories"],
|
try:
|
||||||
"date_range": {
|
logger.info("Listing available metrics")
|
||||||
"start_date": "2023-01-01",
|
|
||||||
"end_date": "2023-12-31"
|
# 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])
|
@router.get("/metrics/query", response_model=List[HealthMetricResponse])
|
||||||
async def query_metrics(
|
async def query_metrics(
|
||||||
metric_type: Optional[str] = Query(None),
|
metric_type: Optional[str] = Query(None),
|
||||||
start_date: Optional[str] = Query(None),
|
start_date: Optional[str] = Query(None),
|
||||||
end_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
|
Query health metrics with filters.
|
||||||
return []
|
"""
|
||||||
|
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)
|
@router.get("/health-data/summary", response_model=HealthDataSummary)
|
||||||
async def get_health_summary(
|
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),
|
start_date: Optional[str] = Query(None),
|
||||||
end_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 aggregated health statistics.
|
||||||
return []
|
"""
|
||||||
|
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 pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
import traceback
|
import logging
|
||||||
import httpx
|
|
||||||
import base64
|
from ..services.garmin.client import GarminClient
|
||||||
import json
|
|
||||||
from ..services.postgresql_manager import PostgreSQLManager
|
from ..services.postgresql_manager import PostgreSQLManager
|
||||||
from ..utils.config import config
|
from ..utils.config import config
|
||||||
import garth
|
|
||||||
from ..services.garmin.client import GarminClient
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
||||||
@@ -24,260 +22,35 @@ class GarminCredentials(BaseModel):
|
|||||||
password: str
|
password: str
|
||||||
is_china: bool = False
|
is_china: bool = False
|
||||||
|
|
||||||
class FitbitCredentials(BaseModel):
|
|
||||||
client_id: str
|
|
||||||
client_secret: str
|
|
||||||
|
|
||||||
class FitbitCallback(BaseModel):
|
|
||||||
callback_url: str
|
|
||||||
|
|
||||||
class GarminMFARequest(BaseModel):
|
class GarminMFARequest(BaseModel):
|
||||||
verification_code: str
|
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")
|
@router.post("/setup/garmin")
|
||||||
async def save_garmin_credentials(credentials: GarminCredentials, db: Session = Depends(get_db)):
|
def save_garmin_credentials(credentials: GarminCredentials, db: Session = Depends(get_db)):
|
||||||
from ..utils.helpers import setup_logger
|
logger.info(f"Received Garmin credentials for user: {credentials.username}")
|
||||||
logger = setup_logger(__name__)
|
|
||||||
|
|
||||||
logger.info(f"Received Garmin credentials for user: {credentials.username}, is_china: {credentials.is_china}")
|
|
||||||
|
|
||||||
garmin_client = GarminClient(credentials.username, credentials.password, credentials.is_china)
|
garmin_client = GarminClient(credentials.username, credentials.password, credentials.is_china)
|
||||||
logger.debug("GarminClient instance created successfully")
|
|
||||||
|
|
||||||
logger.debug("Attempting to log in to Garmin")
|
status = garmin_client.login(db)
|
||||||
# Check the status returned directly
|
|
||||||
status = garmin_client.login()
|
|
||||||
|
|
||||||
if status == "mfa_required":
|
if status == "mfa_required":
|
||||||
# Hardcode the session_id as 'garmin' since you use a single record in APIToken
|
return JSONResponse(status_code=202, content={"status": "mfa_required", "message": "MFA code required."})
|
||||||
return JSONResponse(
|
|
||||||
status_code=200,
|
|
||||||
content={
|
|
||||||
"status": "mfa_required",
|
|
||||||
"message": "MFA Required",
|
|
||||||
"session_id": "garmin"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(status_code=200, content={"status": "success", "message": "Logged in and tokens saved."})
|
||||||
status_code=200,
|
|
||||||
content={"status": "success", "message": "Logged in!"}
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.post("/setup/garmin/mfa")
|
@router.post("/setup/garmin/mfa")
|
||||||
async def complete_garmin_mfa(mfa_request: GarminMFARequest, db: Session = Depends(get_db)):
|
def complete_garmin_mfa(mfa_request: GarminMFARequest, db: Session = Depends(get_db)):
|
||||||
from ..utils.helpers import setup_logger
|
logger.info(f"Received MFA verification code: {'*' * len(mfa_request.verification_code)}")
|
||||||
logger = setup_logger(__name__)
|
|
||||||
|
|
||||||
try:
|
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:
|
if success:
|
||||||
garmin_client = GarminClient()
|
return JSONResponse(status_code=200, content={"status": "success", "message": "MFA verification successful, tokens saved."})
|
||||||
logger.debug(f"Attempting to handle MFA for session: {mfa_request.session_id}")
|
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:
|
except Exception as e:
|
||||||
import traceback
|
logger.error(f"MFA verification failed with exception: {e}", exc_info=True)
|
||||||
traceback.print_exc()
|
raise HTTPException(status_code=500, detail=f"MFA verification failed: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to test Garmin token: {e}")
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,48 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import List, Optional
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
||||||
|
with db_manager.get_db_session() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
class SyncLogResponse(BaseModel):
|
class SyncLogResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
operation: str
|
operation: str
|
||||||
status: str
|
status: str
|
||||||
message: Optional[str]
|
message: Optional[str] = None
|
||||||
start_time: str
|
start_time: datetime
|
||||||
end_time: Optional[str]
|
end_time: Optional[datetime] = None
|
||||||
records_processed: int
|
records_processed: int
|
||||||
records_failed: int
|
records_failed: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
|
||||||
class StatusResponse(BaseModel):
|
class StatusResponse(BaseModel):
|
||||||
total_weight_records: int
|
|
||||||
synced_weight_records: int
|
|
||||||
unsynced_weight_records: int
|
|
||||||
total_activities: int
|
total_activities: int
|
||||||
downloaded_activities: int
|
downloaded_activities: int
|
||||||
recent_logs: List[SyncLogResponse]
|
recent_logs: List[SyncLogResponse]
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status", response_model=StatusResponse)
|
||||||
async def get_status():
|
def get_status(db: Session = Depends(get_db)):
|
||||||
# This would return the current sync status
|
"""Returns the current sync status and recent logs."""
|
||||||
# Implementation will connect with the services layer
|
total_activities = db.query(Activity).count()
|
||||||
return {
|
downloaded_activities = db.query(Activity).filter(Activity.download_status == 'downloaded').count()
|
||||||
"total_weight_records": 100,
|
|
||||||
"synced_weight_records": 85,
|
recent_logs = db.query(SyncLog).order_by(SyncLog.start_time.desc()).limit(10).all()
|
||||||
"unsynced_weight_records": 15,
|
|
||||||
"total_activities": 50,
|
return StatusResponse(
|
||||||
"downloaded_activities": 30,
|
total_activities=total_activities,
|
||||||
"recent_logs": []
|
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 pydantic import BaseModel
|
||||||
from typing import Optional
|
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 ..services.postgresql_manager import PostgreSQLManager
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from ..utils.config import config
|
from ..utils.config import config
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import garth
|
||||||
|
from garth.auth_tokens import OAuth1Token, OAuth2Token
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class SyncActivityRequest(BaseModel):
|
class SyncActivityRequest(BaseModel):
|
||||||
days_back: int = 30
|
days_back: int = 30
|
||||||
@@ -20,32 +29,50 @@ def get_db():
|
|||||||
with db_manager.get_db_session() as session:
|
with db_manager.get_db_session() as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
@router.post("/sync/weight", response_model=SyncResponse)
|
def _load_and_verify_garth_session(db: Session):
|
||||||
async def sync_weight(db: Session = Depends(get_db)):
|
"""Helper to load token from DB and verify session with Garmin."""
|
||||||
# This would trigger the weight sync process
|
logger.info("Loading and verifying Garmin session...")
|
||||||
# Implementation will connect with the services layer
|
token_record = db.query(APIToken).filter_by(token_type='garmin').first()
|
||||||
return {
|
if not (token_record and token_record.garth_oauth1_token and token_record.garth_oauth2_token):
|
||||||
"status": "started",
|
raise HTTPException(status_code=401, detail="Garmin token not found.")
|
||||||
"message": "Weight sync process started",
|
|
||||||
"job_id": "weight-sync-12345"
|
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)
|
@router.post("/sync/activities", response_model=SyncResponse)
|
||||||
async def sync_activities(request: SyncActivityRequest, db: Session = Depends(get_db)):
|
def sync_activities(request: SyncActivityRequest, db: Session = Depends(get_db)):
|
||||||
# This would trigger the activity sync process
|
_load_and_verify_garth_session(db)
|
||||||
# Implementation will connect with the services layer
|
garmin_client = GarminClient() # The client is now just a thin wrapper
|
||||||
return {
|
sync_app = SyncApp(db_session=db, garmin_client=garmin_client)
|
||||||
"status": "started",
|
result = sync_app.sync_activities(days_back=request.days_back)
|
||||||
"message": "Activity sync process started",
|
return SyncResponse(
|
||||||
"job_id": f"activity-sync-{request.days_back}"
|
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)
|
@router.post("/sync/metrics", response_model=SyncResponse)
|
||||||
async def sync_metrics(db: Session = Depends(get_db)):
|
def sync_metrics(db: Session = Depends(get_db)):
|
||||||
# This would trigger the health metrics sync process
|
_load_and_verify_garth_session(db)
|
||||||
# Implementation will connect with the services layer
|
garmin_client = GarminClient()
|
||||||
return {
|
sync_app = SyncApp(db_session=db, garmin_client=garmin_client)
|
||||||
"status": "started",
|
result = sync_app.sync_health_metrics()
|
||||||
"message": "Health metrics sync process started",
|
return SyncResponse(
|
||||||
"job_id": "metrics-sync-12345"
|
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
|
import json
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from garth.exc import GarthException
|
from garth.exc import GarthException
|
||||||
from src.models.api_token import APIToken
|
from sqlalchemy.orm import Session
|
||||||
from src.services.postgresql_manager import PostgreSQLManager
|
import logging
|
||||||
from src.utils.config import config
|
|
||||||
from src.utils.helpers import setup_logger
|
|
||||||
|
|
||||||
logger = setup_logger(__name__)
|
from ...models.api_token import APIToken
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class AuthMixin:
|
class AuthMixin:
|
||||||
def login(self):
|
def login(self, db: Session):
|
||||||
"""Login to Garmin Connect, returning status instead of raising exceptions."""
|
"""Login to Garmin Connect, returning status instead of raising exceptions."""
|
||||||
logger.info(f"Starting login for: {self.username}")
|
logger.info(f"Starting login for: {self.username}")
|
||||||
try:
|
try:
|
||||||
# result1 is status, result2 is the mfa_state dict or tokens
|
garth.login(self.username, self.password)
|
||||||
result1, result2 = garth.login(self.username, self.password, return_on_mfa=True)
|
self.update_tokens(db, garth.client.oauth1_token, garth.client.oauth2_token)
|
||||||
|
|
||||||
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)
|
|
||||||
self.is_connected = True
|
self.is_connected = True
|
||||||
return "success"
|
return "success"
|
||||||
except GarthException as e:
|
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}")
|
logger.error(f"Login failed: {e}")
|
||||||
return "error"
|
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."""
|
"""Saves the Garmin OAuth tokens to the database."""
|
||||||
logger.info(f"Updating Garmin tokens for user: {self.username}")
|
logger.info(f"Updating Garmin tokens for user: {self.username}")
|
||||||
|
|
||||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
token_record = db.query(APIToken).filter_by(token_type='garmin').first()
|
||||||
with db_manager.get_db_session() as session:
|
if not token_record:
|
||||||
token_record = session.query(APIToken).filter_by(token_type='garmin').first()
|
token_record = APIToken(token_type='garmin')
|
||||||
if not token_record:
|
db.add(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.garth_oauth1_token = json.dumps(oauth1)
|
token_record.updated_at = datetime.now()
|
||||||
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
|
||||||
# Clear MFA state as it's no longer needed
|
|
||||||
token_record.mfa_state = None
|
db.commit()
|
||||||
token_record.mfa_expires_at = None
|
logger.info("Garmin tokens updated successfully.")
|
||||||
|
|
||||||
session.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."""
|
"""Saves ONLY serializable parts of the MFA state to the database."""
|
||||||
logger.info(f"Initiating MFA process for user: {self.username}")
|
logger.info(f"Initiating MFA process for user: {self.username}")
|
||||||
|
|
||||||
# FIX: Extract serializable data. We cannot dump the 'client' object directly.
|
|
||||||
serializable_state = {
|
serializable_state = {
|
||||||
"signin_params": mfa_state["signin_params"],
|
"signin_params": mfa_state["signin_params"],
|
||||||
"cookies": mfa_state["client"].sess.cookies.get_dict(),
|
"cookies": mfa_state["client"].sess.cookies.get_dict(),
|
||||||
"domain": mfa_state["client"].domain
|
"domain": mfa_state["client"].domain
|
||||||
}
|
}
|
||||||
|
|
||||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
token_record = db.query(APIToken).filter_by(token_type='garmin').first()
|
||||||
with db_manager.get_db_session() as session:
|
if not token_record:
|
||||||
token_record = session.query(APIToken).filter_by(token_type='garmin').first()
|
token_record = APIToken(token_type='garmin')
|
||||||
if not token_record:
|
db.add(token_record)
|
||||||
token_record = APIToken(token_type='garmin')
|
|
||||||
session.add(token_record)
|
token_record.mfa_state = json.dumps(serializable_state)
|
||||||
|
token_record.mfa_expires_at = datetime.now() + timedelta(minutes=10)
|
||||||
# Save the dictionary as a string
|
db.commit()
|
||||||
token_record.mfa_state = json.dumps(serializable_state)
|
|
||||||
token_record.mfa_expires_at = datetime.now() + timedelta(minutes=10)
|
|
||||||
session.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."""
|
"""Reconstructs the Garth state and completes authentication."""
|
||||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
token_record = db.query(APIToken).filter_by(token_type='garmin').first()
|
||||||
with db_manager.get_db_session() as session:
|
if not token_record or not token_record.mfa_state:
|
||||||
token_record = session.query(APIToken).filter_by(token_type='garmin').first()
|
raise Exception("No pending MFA session found.")
|
||||||
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)
|
||||||
|
|
||||||
saved_data = json.loads(token_record.mfa_state)
|
from garth.http import Client
|
||||||
|
client = Client(domain=saved_data["domain"])
|
||||||
# FIX: Reconstruct the Garth Client and State object
|
client.sess.cookies.update(saved_data["cookies"])
|
||||||
from garth.http import Client
|
|
||||||
client = Client(domain=saved_data["domain"])
|
mfa_state = {
|
||||||
client.sess.cookies.update(saved_data["cookies"])
|
"client": client,
|
||||||
|
"signin_params": saved_data["signin_params"]
|
||||||
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)
|
||||||
try:
|
return True
|
||||||
oauth1, oauth2 = garth.resume_login(mfa_state, verification_code)
|
except GarthException as e:
|
||||||
self.update_tokens(oauth1, oauth2)
|
logger.error(f"MFA handling failed: {e}")
|
||||||
# ... rest of your session cleanup ...
|
raise
|
||||||
return True
|
|
||||||
except GarthException as e:
|
|
||||||
logger.error(f"MFA handling failed: {e}")
|
|
||||||
raise
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import garth
|
import garth
|
||||||
from src.utils.helpers import setup_logger
|
import logging
|
||||||
from .auth import AuthMixin
|
from .auth import AuthMixin
|
||||||
from .data import DataMixin
|
from .data import DataMixin
|
||||||
|
|
||||||
logger = setup_logger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class GarminClient(AuthMixin, DataMixin):
|
class GarminClient(AuthMixin, DataMixin):
|
||||||
def __init__(self, username: str = None, password: str = None, is_china: bool = False):
|
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.garmin_client = None
|
||||||
self.is_connected = False
|
self.is_connected = False
|
||||||
|
|
||||||
logger.debug(f"Initializing GarminClient for user: {username}, is_china: {is_china}")
|
|
||||||
|
|
||||||
if is_china:
|
if is_china:
|
||||||
logger.debug("Configuring garth for China domain")
|
|
||||||
garth.configure(domain="garmin.cn")
|
garth.configure(domain="garmin.cn")
|
||||||
|
|
||||||
if username and password:
|
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 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:
|
class DataMixin:
|
||||||
def upload_weight(self, weight: float, unit: str = 'kg', timestamp: datetime = None) -> bool:
|
"""
|
||||||
"""Upload weight entry to Garmin Connect."""
|
Mixin for Garmin data fetching operations using the garth library.
|
||||||
if not self.is_connected:
|
Assumes that the global garth client has been authenticated.
|
||||||
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
|
|
||||||
|
|
||||||
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."""
|
"""Fetch activity list from Garmin Connect."""
|
||||||
if not self.is_connected:
|
logger.info(f"Fetching activities from {start_date} to {end_date}")
|
||||||
raise Exception("Not connected to Garmin Connect")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not end_date:
|
return garth.client.connectapi(
|
||||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
"/activitylist-service/activities/search/activities",
|
||||||
|
params={"startDate": start_date, "endDate": end_date, "limit": limit}
|
||||||
activities = self.garmin_client.get_activities(start_date, end_date)
|
)
|
||||||
logger.info(f"Fetched {len(activities)} activities from Garmin")
|
|
||||||
return activities
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching activities from Garmin: {str(e)}")
|
logger.error(f"Error fetching activities from Garmin: {e}")
|
||||||
raise e
|
raise
|
||||||
|
|
||||||
def download_activity(self, activity_id: str, file_type: str = 'tcx') -> Optional[bytes]:
|
def download_activity(self, activity_id: str, file_type: str = 'original') -> Optional[bytes]:
|
||||||
"""Download activity file from Garmin Connect and return its content."""
|
"""
|
||||||
if not self.is_connected:
|
Download an activity file from Garmin Connect.
|
||||||
raise Exception("Not connected to Garmin Connect")
|
'file_type' can be 'tcx', 'gpx', 'fit', or 'original'.
|
||||||
|
"""
|
||||||
|
logger.info(f"Downloading activity {activity_id} as {file_type}")
|
||||||
try:
|
try:
|
||||||
file_content = self.garmin_client.get_activity_details(activity_id)
|
path = f"/download-service/export/{file_type}/activity/{activity_id}"
|
||||||
logger.info(f"Downloaded activity {activity_id} as {file_type} format")
|
return garth.client.download(path)
|
||||||
return file_content if file_content else b""
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error downloading activity {activity_id} from Garmin: {str(e)}")
|
logger.error(f"Error downloading activity {activity_id} as {file_type}: {e}")
|
||||||
raise e
|
return None
|
||||||
|
|
||||||
def get_heart_rates(self, start_date: str, end_date: str = None) -> Dict[str, Any]:
|
def get_daily_metrics(self, start_date: str, end_date: str) -> Dict[str, List[Dict]]:
|
||||||
"""Fetch heart rate data from Garmin Connect."""
|
"""
|
||||||
if not self.is_connected:
|
Fetch various daily metrics for a given date range.
|
||||||
raise Exception("Not connected to Garmin Connect")
|
"""
|
||||||
|
start = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||||
try:
|
end = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||||
if not end_date:
|
days = (end - start).days + 1
|
||||||
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_sleep_data(self, start_date: str, end_date: str = None) -> Dict[str, Any]:
|
all_metrics = {
|
||||||
"""Fetch sleep data from Garmin Connect."""
|
"steps": [],
|
||||||
if not self.is_connected:
|
"hrv": [],
|
||||||
raise Exception("Not connected to Garmin Connect")
|
"sleep": []
|
||||||
|
}
|
||||||
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
|
|
||||||
|
|
||||||
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:
|
try:
|
||||||
if not end_date:
|
logger.info(f"Fetching daily steps for {days} days ending on {end_date}")
|
||||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
all_metrics["steps"] = DailySteps.list(end, period=days)
|
||||||
|
|
||||||
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
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching steps data from Garmin: {str(e)}")
|
logger.error(f"Error fetching daily steps: {e}")
|
||||||
raise 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:
|
try:
|
||||||
if not end_date:
|
logger.info(f"Fetching daily HRV for {days} days ending on {end_date}")
|
||||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
all_metrics["hrv"] = DailyHRV.list(end, period=days)
|
||||||
|
|
||||||
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
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching all metrics from Garmin: {str(e)}")
|
logger.error(f"Error fetching daily HRV: {e}")
|
||||||
raise 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 ..models.sync_log import SyncLog
|
||||||
from ..services.fitbit_client import FitbitClient
|
|
||||||
from ..services.garmin.client import GarminClient
|
from ..services.garmin.client import GarminClient
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
import logging
|
import logging
|
||||||
from ..utils.helpers import setup_logger
|
|
||||||
|
|
||||||
logger = setup_logger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class SyncApp:
|
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.db_session = db_session
|
||||||
self.fitbit_client = fitbit_client
|
|
||||||
self.garmin_client = garmin_client
|
self.garmin_client = garmin_client
|
||||||
|
self.fitbit_client = fitbit_client
|
||||||
def sync_weight_data(self, start_date: str = None, end_date: str = None) -> Dict[str, int]:
|
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
|
||||||
"""Sync weight data from Fitbit to Garmin."""
|
self.logger.info("SyncApp initialized")
|
||||||
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
|
|
||||||
|
|
||||||
def sync_activities(self, days_back: int = 30) -> Dict[str, int]:
|
def sync_activities(self, days_back: int = 30) -> Dict[str, int]:
|
||||||
"""Sync activity data from Garmin to local storage."""
|
"""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')
|
start_date = (datetime.now() - timedelta(days=days_back)).strftime('%Y-%m-%d')
|
||||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
|
||||||
# Create a sync log entry
|
self.logger.info(f"Date range: {start_date} to {end_date}")
|
||||||
sync_log = SyncLog(
|
|
||||||
operation="activity_archive",
|
sync_log = SyncLog(operation="activity_sync", status="started", start_time=datetime.now())
|
||||||
status="started",
|
|
||||||
start_time=datetime.now(),
|
|
||||||
records_processed=0,
|
|
||||||
records_failed=0
|
|
||||||
)
|
|
||||||
self.db_session.add(sync_log)
|
self.db_session.add(sync_log)
|
||||||
self.db_session.commit()
|
self.db_session.commit()
|
||||||
|
|
||||||
|
processed_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Fetch activities from Garmin
|
self.logger.info("Fetching activities from Garmin...")
|
||||||
garmin_activities = self.garmin_client.get_activities(start_date, end_date)
|
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
|
for activity_data in garmin_activities:
|
||||||
failed_count = 0
|
activity_id = str(activity_data.get('activityId'))
|
||||||
|
if not activity_id:
|
||||||
from ..models.activity import Activity
|
self.logger.warning("Skipping activity with no ID.")
|
||||||
for activity in garmin_activities:
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
activity_id = str(activity.get('activityId', ''))
|
existing_activity = self.db_session.query(Activity).filter_by(garmin_activity_id=activity_id).first()
|
||||||
existing_activity = self.db_session.query(Activity).filter(
|
|
||||||
Activity.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:
|
if not existing_activity:
|
||||||
activity_record = Activity(
|
activity_type_dict = activity_data.get('activityType', {})
|
||||||
|
existing_activity = Activity(
|
||||||
garmin_activity_id=activity_id,
|
garmin_activity_id=activity_id,
|
||||||
activity_name=activity.get('activityName', ''),
|
activity_name=activity_data.get('activityName'),
|
||||||
activity_type=activity.get('activityType', ''),
|
activity_type=activity_type_dict.get('typeKey', 'unknown'),
|
||||||
start_time=datetime.fromisoformat(activity.get('startTimeLocal', '')) if activity.get('startTimeLocal') else None,
|
start_time=datetime.fromisoformat(activity_data.get('startTimeLocal')) if activity_data.get('startTimeLocal') else None,
|
||||||
duration=activity.get('duration', 0),
|
duration=activity_data.get('duration', 0),
|
||||||
download_status='pending'
|
download_status='pending'
|
||||||
)
|
)
|
||||||
self.db_session.add(activity_record)
|
self.db_session.add(existing_activity)
|
||||||
self.db_session.flush()
|
|
||||||
else:
|
|
||||||
activity_record = existing_activity
|
|
||||||
|
|
||||||
# Download activity file if not already downloaded
|
if existing_activity.download_status != 'downloaded':
|
||||||
if activity_record.download_status != 'downloaded':
|
|
||||||
# Download in various formats
|
|
||||||
file_formats = ['tcx', 'gpx', 'fit']
|
|
||||||
downloaded_successfully = False
|
downloaded_successfully = False
|
||||||
|
for fmt in ['original', 'tcx', 'gpx', 'fit']:
|
||||||
for fmt in file_formats:
|
file_content = self.garmin_client.download_activity(activity_id, file_type=fmt)
|
||||||
try:
|
if file_content:
|
||||||
# Get file content from Garmin client
|
existing_activity.file_content = file_content
|
||||||
file_content = self.garmin_client.download_activity(activity_id, file_type=fmt)
|
existing_activity.file_type = fmt
|
||||||
if file_content:
|
existing_activity.download_status = 'downloaded'
|
||||||
# Store file content directly in the database
|
existing_activity.downloaded_at = datetime.now()
|
||||||
activity_record.file_content = file_content
|
self.logger.info(f"✓ Successfully downloaded {activity_id} as {fmt}")
|
||||||
activity_record.file_type = fmt
|
downloaded_successfully = True
|
||||||
activity_record.download_status = 'downloaded'
|
break
|
||||||
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
|
|
||||||
|
|
||||||
if not downloaded_successfully:
|
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
|
failed_count += 1
|
||||||
|
else:
|
||||||
processed_count += 1
|
processed_count += 1
|
||||||
|
else:
|
||||||
|
self.logger.info(f"Activity {activity_id} already downloaded. Skipping.")
|
||||||
|
|
||||||
|
self.db_session.commit()
|
||||||
|
|
||||||
except Exception as e:
|
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
|
failed_count += 1
|
||||||
|
self.db_session.rollback()
|
||||||
|
|
||||||
# Update sync log with results
|
sync_log.status = "completed_with_errors" if failed_count > 0 else "completed"
|
||||||
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_processed = processed_count
|
||||||
sync_log.records_failed = failed_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:
|
except Exception as e:
|
||||||
logger.error(f"Error during activity sync: {str(e)}")
|
self.logger.error(f"Major error during activity sync: {e}", exc_info=True)
|
||||||
|
|
||||||
# Update sync log with error status
|
|
||||||
sync_log.status = "failed"
|
sync_log.status = "failed"
|
||||||
sync_log.end_time = datetime.now()
|
|
||||||
sync_log.message = str(e)
|
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:
|
sync_log.end_time = datetime.now()
|
||||||
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)
|
|
||||||
self.db_session.commit()
|
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:
|
try:
|
||||||
# Fetch all metrics from Garmin
|
daily_metrics = self.garmin_client.get_daily_metrics(start_date, end_date)
|
||||||
all_metrics = self.garmin_client.get_all_metrics(start_date, end_date)
|
|
||||||
|
for steps_data in daily_metrics.get("steps", []):
|
||||||
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:
|
|
||||||
try:
|
try:
|
||||||
metric = HealthMetric(
|
self._update_or_create_metric('steps', steps_data.calendar_date, steps_data.total_steps, 'steps')
|
||||||
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)
|
|
||||||
processed_count += 1
|
processed_count += 1
|
||||||
except Exception as e:
|
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
|
failed_count += 1
|
||||||
|
|
||||||
# Update sync log with results
|
for hrv_data in daily_metrics.get("hrv", []):
|
||||||
sync_log.status = "completed" if failed_count == 0 else "completed_with_errors"
|
try:
|
||||||
sync_log.end_time = datetime.now()
|
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_processed = processed_count
|
||||||
sync_log.records_failed = failed_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:
|
except Exception as e:
|
||||||
logger.error(f"Error during health metrics sync: {str(e)}")
|
self.logger.error(f"Major error during health metrics sync: {e}", exc_info=True)
|
||||||
|
|
||||||
# Update sync log with error status
|
|
||||||
sync_log.status = "failed"
|
sync_log.status = "failed"
|
||||||
sync_log.end_time = datetime.now()
|
|
||||||
sync_log.message = str(e)
|
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()
|
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
|
from typing import Optional
|
||||||
import os
|
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:
|
def get_current_timestamp() -> str:
|
||||||
"""Get current timestamp in ISO format."""
|
"""Get current timestamp in ISO format."""
|
||||||
return datetime.utcnow().isoformat()
|
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)}")
|
print(f"Missing required environment variables: {', '.join(missing_vars)}")
|
||||||
return False
|
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">
|
<div class="container mt-5">
|
||||||
<h1>Fitbit-Garmin Sync Dashboard</h1>
|
<h1>Fitbit-Garmin Sync Dashboard</h1>
|
||||||
|
|
||||||
<div class="row mb-4">
|
<!-- Toast container for notifications -->
|
||||||
<div class="col-md-4">
|
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||||
<div class="card">
|
<div id="appToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||||
<div class="card-body">
|
<div class="toast-header">
|
||||||
<h5 class="card-title">Weight Records</h5>
|
<strong class="me-auto" id="toast-title">Notification</strong>
|
||||||
<p class="card-text">Total: <span id="total-weights">0</span></p>
|
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
<p class="card-text">Synced: <span id="synced-weights">0</span></p>
|
</div>
|
||||||
<p class="card-text">Unsynced: <span id="unsynced-weights">0</span></p>
|
<div class="toast-body" id="toast-body">
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">Activities</h5>
|
<h5 class="card-title">Activities</h5>
|
||||||
@@ -30,13 +32,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">Sync Status</h5>
|
<h5 class="card-title">Sync Controls</h5>
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<button class="btn btn-primary" type="button" id="sync-weight-btn">Sync Weight</button>
|
<button class="btn btn-primary" type="button" id="sync-activities-btn">Sync Activities</button>
|
||||||
<button class="btn btn-secondary" 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,13 +55,15 @@
|
|||||||
<th>Operation</th>
|
<th>Operation</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Start Time</th>
|
<th>Start Time</th>
|
||||||
<th>Records Processed</th>
|
<th>End Time</th>
|
||||||
<th>Records Failed</th>
|
<th>Processed</th>
|
||||||
|
<th>Failed</th>
|
||||||
|
<th>Message</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5">Loading logs...</td>
|
<td colspan="7">Loading logs...</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -69,71 +73,11 @@
|
|||||||
|
|
||||||
<div class="row mt-5">
|
<div class="row mt-5">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<h3>Health Metrics</h3>
|
<h3>Actions</h5>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<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>
|
||||||
<button class="btn btn-info me-md-2" type="button" id="sync-metrics-btn">Sync Health Metrics</button>
|
<a href="/docs" class="btn btn-outline-secondary" target="_blank">API Documentation</a>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Load dashboard data when page loads
|
let toastInstance = null;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const toastEl = document.getElementById('appToast');
|
||||||
|
toastInstance = new bootstrap.Toast(toastEl);
|
||||||
|
|
||||||
loadDashboardData();
|
loadDashboardData();
|
||||||
|
|
||||||
// Set up sync buttons
|
|
||||||
document.getElementById('sync-weight-btn').addEventListener('click', syncWeight);
|
|
||||||
document.getElementById('sync-activities-btn').addEventListener('click', syncActivities);
|
document.getElementById('sync-activities-btn').addEventListener('click', syncActivities);
|
||||||
|
|
||||||
// Set up metrics buttons
|
|
||||||
document.getElementById('sync-metrics-btn').addEventListener('click', syncHealthMetrics);
|
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() {
|
async function loadDashboardData() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/status');
|
const response = await fetch('/api/status');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
const data = await response.json();
|
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('total-activities').textContent = data.total_activities;
|
||||||
document.getElementById('downloaded-activities').textContent = data.downloaded_activities;
|
document.getElementById('downloaded-activities').textContent = data.downloaded_activities;
|
||||||
|
|
||||||
// Update logs table
|
|
||||||
const logsBody = document.querySelector('#sync-logs-table tbody');
|
const logsBody = document.querySelector('#sync-logs-table tbody');
|
||||||
logsBody.innerHTML = '';
|
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 => {
|
data.recent_logs.forEach(log => {
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<td>${log.operation}</td>
|
<td>${log.operation}</td>
|
||||||
<td>${log.status}</td>
|
<td><span class="badge bg-${log.status === 'completed' ? 'success' : 'warning'}">${log.status}</span></td>
|
||||||
<td>${log.start_time}</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_processed}</td>
|
||||||
<td>${log.records_failed}</td>
|
<td>${log.records_failed}</td>
|
||||||
|
<td>${log.message || ''}</td>
|
||||||
`;
|
`;
|
||||||
logsBody.appendChild(row);
|
logsBody.appendChild(row);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading dashboard data:', error);
|
console.error('Error loading dashboard data:', error);
|
||||||
}
|
showToast('Error', 'Could not load 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncActivities() {
|
async function syncActivities() {
|
||||||
|
showToast('Syncing...', 'Activity sync has been initiated.', 'info');
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/sync/activities', {
|
const response = await fetch('/api/sync/activities', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ days_back: 30 })
|
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();
|
const data = await response.json();
|
||||||
alert(`Activity sync initiated: ${data.message}`);
|
showToast('Sync Complete', data.message, 'success');
|
||||||
|
loadDashboardData(); // Refresh data after sync
|
||||||
// Refresh dashboard data
|
|
||||||
loadDashboardData();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error syncing activities:', 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() {
|
async function syncHealthMetrics() {
|
||||||
|
showToast('Syncing...', 'Health metrics sync has been initiated.', 'info');
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/sync/metrics', {
|
const response = await fetch('/api/sync/metrics', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' }
|
||||||
'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();
|
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) {
|
} catch (error) {
|
||||||
console.error('Error syncing health metrics:', error);
|
console.error('Error syncing health metrics:', error);
|
||||||
alert('Error initiating health metrics sync: ' + error.message);
|
showToast('Sync Error', `Health metrics sync failed: ${error.message}`, 'error');
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</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