feat: Update spec, fix bugs, improve UI/UX, and clean up code

This commit is contained in:
2025-12-25 08:33:01 -08:00
parent 8fe375a966
commit df9dcb2f79
21 changed files with 1741 additions and 1055 deletions

View File

@@ -1,9 +1,22 @@
from fastapi import APIRouter, Query, Response
from fastapi import APIRouter, Query, Response, HTTPException, Depends
from pydantic import BaseModel
from typing import List, Optional, Dict, Any
from sqlalchemy import func
from ..models.activity import Activity
import logging
from ..services.postgresql_manager import PostgreSQLManager
from sqlalchemy.orm import Session
from ..utils.config import config
router = APIRouter()
logger = logging.getLogger(__name__)
def get_db():
db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session:
yield session
class ActivityResponse(BaseModel):
id: Optional[int] = None
garmin_activity_id: Optional[str] = None
@@ -19,26 +32,143 @@ class ActivityResponse(BaseModel):
@router.get("/activities/list", response_model=List[ActivityResponse])
async def list_activities(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0)
offset: int = Query(0, ge=0),
db: Session = Depends(get_db)
):
# This would return metadata for all downloaded/available activities
# Implementation will connect with the services layer
return []
"""
Return metadata for all downloaded/available activities.
"""
try:
logger.info(f"Listing activities with limit={limit}, offset={offset}")
# Query the database for activities
activities = db.query(Activity).offset(offset).limit(limit).all()
# Convert SQLAlchemy objects to Pydantic models
activity_responses = []
for activity in activities:
activity_responses.append(
ActivityResponse(
id=activity.id,
garmin_activity_id=activity.garmin_activity_id,
activity_name=activity.activity_name,
activity_type=activity.activity_type,
start_time=activity.start_time.isoformat() if activity.start_time else None,
duration=activity.duration,
file_type=activity.file_type,
download_status=activity.download_status,
downloaded_at=activity.downloaded_at.isoformat() if activity.downloaded_at else None
)
)
logger.info(f"Returning {len(activity_responses)} activities")
return activity_responses
except Exception as e:
logger.error(f"Error in list_activities: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error listing activities: {str(e)}")
@router.get("/activities/query", response_model=List[ActivityResponse])
async def query_activities(
activity_type: Optional[str] = Query(None),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
download_status: Optional[str] = Query(None)
download_status: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
# This would allow advanced filtering of activities
# Implementation will connect with the services layer
return []
"""
Allow advanced filtering of activities.
"""
try:
logger.info(f"Querying activities - type: {activity_type}, start: {start_date}, end: {end_date}, status: {download_status}")
# Start building the query
query = db.query(Activity)
# Apply filters based on parameters
if activity_type:
query = query.filter(Activity.activity_type == activity_type)
if start_date:
from datetime import datetime
start_dt = datetime.fromisoformat(start_date)
query = query.filter(Activity.start_time >= start_dt)
if end_date:
from datetime import datetime
end_dt = datetime.fromisoformat(end_date)
query = query.filter(Activity.start_time <= end_dt)
if download_status:
query = query.filter(Activity.download_status == download_status)
# Execute the query
activities = query.all()
# Convert SQLAlchemy objects to Pydantic models
activity_responses = []
for activity in activities:
activity_responses.append(
ActivityResponse(
id=activity.id,
garmin_activity_id=activity.garmin_activity_id,
activity_name=activity.activity_name,
activity_type=activity.activity_type,
start_time=activity.start_time.isoformat() if activity.start_time else None,
duration=activity.duration,
file_type=activity.file_type,
download_status=activity.download_status,
downloaded_at=activity.downloaded_at.isoformat() if activity.downloaded_at else None
)
)
logger.info(f"Returning {len(activity_responses)} filtered activities")
return activity_responses
except Exception as e:
logger.error(f"Error in query_activities: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error querying activities: {str(e)}")
@router.get("/activities/download/{activity_id}")
async def download_activity(activity_id: str):
# This would serve the stored activity file from the database
# Implementation will connect with the services layer
# It should return the file content with appropriate content-type
return Response(content=b"sample_content", media_type="application/octet-stream", headers={"Content-Disposition": f"attachment; filename=activity_{activity_id}.tcx"})
async def download_activity(activity_id: str, db: Session = Depends(get_db)):
"""
Serve the stored activity file from the database.
"""
try:
logger.info(f"Downloading activity with ID: {activity_id}")
# Find the activity in the database
activity = db.query(Activity).filter(Activity.garmin_activity_id == activity_id).first()
if not activity:
raise HTTPException(status_code=404, detail=f"Activity with ID {activity_id} not found")
if not activity.file_content:
raise HTTPException(status_code=404, detail=f"No file content available for activity {activity_id}")
if activity.download_status != 'downloaded':
raise HTTPException(status_code=400, detail=f"File for activity {activity_id} is not ready for download (status: {activity.download_status})")
# Determine the appropriate content type based on the file type
content_type_map = {
'tcx': 'application/vnd.garmin.tcx+xml',
'gpx': 'application/gpx+xml',
'fit': 'application/octet-stream' # FIT files are binary
}
content_type = content_type_map.get(activity.file_type, 'application/octet-stream')
filename = f"activity_{activity_id}.{activity.file_type}"
logger.info(f"Returning file for activity {activity_id} with content type {content_type}")
return Response(
content=activity.file_content,
media_type=content_type,
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(activity.file_content))
}
)
except HTTPException:
# Re-raise HTTP exceptions as-is
raise
except Exception as e:
logger.error(f"Error in download_activity for ID {activity_id}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error downloading activity: {str(e)}")

View File

@@ -1,9 +1,22 @@
from fastapi import APIRouter, Query
from fastapi import APIRouter, Query, HTTPException, Depends
from pydantic import BaseModel
from typing import List, Optional, Dict, Any
from sqlalchemy import func
from ..models.health_metric import HealthMetric
import logging
from ..services.postgresql_manager import PostgreSQLManager
from sqlalchemy.orm import Session
from ..utils.config import config
router = APIRouter()
logger = logging.getLogger(__name__)
def get_db():
db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session:
yield session
class HealthMetricResponse(BaseModel):
id: int
metric_type: str
@@ -28,71 +41,188 @@ class HealthDataSummary(BaseModel):
total_sleep_hours: Optional[float] = 0.0
avg_calories: Optional[float] = 0.0
class ActivityResponse(BaseModel):
id: Optional[int] = None
garmin_activity_id: Optional[str] = None
activity_name: Optional[str] = None
activity_type: Optional[str] = None
start_time: Optional[str] = None
duration: Optional[int] = None
file_path: Optional[str] = None
file_type: Optional[str] = None
download_status: Optional[str] = None
downloaded_at: Optional[str] = None
@router.get("/metrics/list", response_model=MetricsListResponse)
async def list_available_metrics():
# This would return available metric types and date ranges
# Implementation will connect with the services layer
return {
"metric_types": ["steps", "heart_rate", "sleep", "calories"],
"date_range": {
"start_date": "2023-01-01",
"end_date": "2023-12-31"
async def list_available_metrics(db: Session = Depends(get_db)):
"""
Return available metric types and date ranges.
"""
try:
logger.info("Listing available metrics")
# Query for distinct metric types from the database
metric_types_result = db.query(HealthMetric.metric_type).distinct().all()
metric_types = [row[0] for row in metric_types_result if row[0] is not None]
# Find the date range of available metrics
min_date_result = db.query(func.min(HealthMetric.date)).scalar()
max_date_result = db.query(func.max(HealthMetric.date)).scalar()
start_date = min_date_result.isoformat() if min_date_result else None
end_date = max_date_result.isoformat() if max_date_result else None
response = {
"metric_types": metric_types,
"date_range": {
"start_date": start_date,
"end_date": end_date
}
}
}
logger.info(f"Returning {len(metric_types)} metric types with date range {start_date} to {end_date}")
return response
except Exception as e:
logger.error(f"Error in list_available_metrics: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error listing metrics: {str(e)}")
@router.get("/metrics/query", response_model=List[HealthMetricResponse])
async def query_metrics(
metric_type: Optional[str] = Query(None),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=1000)
limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_db)
):
# This would query health metrics with filters
# Implementation will connect with the services layer
return []
"""
Query health metrics with filters.
"""
try:
logger.info(f"Querying metrics - type: {metric_type}, start: {start_date}, end: {end_date}, limit: {limit}")
# Start building the query
query = db.query(HealthMetric)
# Apply filters based on parameters
if metric_type:
query = query.filter(HealthMetric.metric_type == metric_type)
if start_date:
from datetime import datetime
start_dt = datetime.fromisoformat(start_date)
query = query.filter(HealthMetric.date >= start_dt.date())
if end_date:
from datetime import datetime
end_dt = datetime.fromisoformat(end_date)
query = query.filter(HealthMetric.date <= end_dt.date())
# Apply limit
query = query.limit(limit)
# Execute the query
health_metrics = query.all()
# Convert SQLAlchemy objects to Pydantic models
metric_responses = []
for metric in health_metrics:
metric_responses.append(
HealthMetricResponse(
id=metric.id,
metric_type=metric.metric_type,
metric_value=metric.metric_value,
unit=metric.unit,
timestamp=metric.timestamp.isoformat() if metric.timestamp else "",
date=metric.date.isoformat() if metric.date else "",
source=metric.source,
detailed_data=metric.detailed_data
)
)
logger.info(f"Returning {len(metric_responses)} health metrics")
return metric_responses
except Exception as e:
logger.error(f"Error in query_metrics: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error querying metrics: {str(e)}")
@router.get("/health-data/summary", response_model=HealthDataSummary)
async def get_health_summary(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None)
):
# This would return aggregated health statistics
# Implementation will connect with the services layer
return {
"total_steps": 123456,
"avg_heart_rate": 72.5,
"total_sleep_hours": 210.5,
"avg_calories": 2345.6
}
@router.get("/activities/list", response_model=List[ActivityResponse])
async def list_activities(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0)
):
# This would return metadata for all downloaded/available activities
# Implementation will connect with the services layer
return []
@router.get("/activities/query", response_model=List[ActivityResponse])
async def query_activities(
activity_type: Optional[str] = Query(None),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
download_status: Optional[str] = Query(None)
db: Session = Depends(get_db)
):
# This would allow advanced filtering of activities
# Implementation will connect with the services layer
return []
"""
Return aggregated health statistics.
"""
try:
logger.info(f"Getting health summary - start: {start_date}, end: {end_date}")
# Start building the query for steps
steps_query = db.query(func.sum(HealthMetric.metric_value)).filter(HealthMetric.metric_type == 'steps')
# Apply date filters to steps query
if start_date:
from datetime import datetime
start_dt = datetime.fromisoformat(start_date)
steps_query = steps_query.filter(HealthMetric.date >= start_dt.date())
if end_date:
from datetime import datetime
end_dt = datetime.fromisoformat(end_date)
steps_query = steps_query.filter(HealthMetric.date <= end_dt.date())
# Calculate total steps
total_steps_result = steps_query.scalar()
total_steps = int(total_steps_result) if total_steps_result is not None else 0
# Build query for heart rate
hr_query = db.query(func.avg(HealthMetric.metric_value)).filter(HealthMetric.metric_type == 'heart_rate')
# Apply date filters to heart rate query
if start_date:
from datetime import datetime
start_dt = datetime.fromisoformat(start_date)
hr_query = hr_query.filter(HealthMetric.date >= start_dt.date())
if end_date:
end_dt = datetime.fromisoformat(end_date)
hr_query = hr_query.filter(HealthMetric.date <= end_dt.date())
# Calculate average heart rate
avg_hr_result = hr_query.scalar()
avg_heart_rate = float(avg_hr_result) if avg_hr_result is not None else 0.0
# Calculate total sleep hours - assuming sleep data is stored in minutes in the database
sleep_query = db.query(func.sum(HealthMetric.metric_value)).filter(HealthMetric.metric_type == 'sleep')
# Apply date filters to sleep query
if start_date:
from datetime import datetime
start_dt = datetime.fromisoformat(start_date)
sleep_query = sleep_query.filter(HealthMetric.date >= start_dt.date())
if end_date:
end_dt = datetime.fromisoformat(end_date)
sleep_query = sleep_query.filter(HealthMetric.date <= end_dt.date())
# Calculate total sleep in minutes, then convert to hours
total_sleep_minutes_result = sleep_query.scalar()
total_sleep_hours = (total_sleep_minutes_result / 60) if total_sleep_minutes_result is not None else 0.0
# Calculate average calories - assuming we have calories data
calories_query = db.query(func.avg(HealthMetric.metric_value)).filter(HealthMetric.metric_type == 'calories')
# Apply date filters to calories query
if start_date:
from datetime import datetime
start_dt = datetime.fromisoformat(start_date)
calories_query = calories_query.filter(HealthMetric.date >= start_dt.date())
if end_date:
end_dt = datetime.fromisoformat(end_date)
calories_query = calories_query.filter(HealthMetric.date <= end_dt.date())
# Calculate average calories
avg_calories_result = calories_query.scalar()
avg_calories = float(avg_calories_result) if avg_calories_result is not None else 0.0
summary = HealthDataSummary(
total_steps=total_steps,
avg_heart_rate=round(avg_heart_rate, 2),
total_sleep_hours=round(total_sleep_hours, 2),
avg_calories=round(avg_calories, 2)
)
logger.info(f"Returning health summary: steps={total_steps}, avg_hr={avg_heart_rate}, sleep_hours={total_sleep_hours}, avg_calories={avg_calories}")
return summary
except Exception as e:
logger.error(f"Error in get_health_summary: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error getting health summary: {str(e)}")

View File

@@ -3,16 +3,14 @@ from fastapi.responses import JSONResponse
from pydantic import BaseModel
from typing import Optional
from sqlalchemy.orm import Session
import traceback
import httpx
import base64
import json
import logging
from ..services.garmin.client import GarminClient
from ..services.postgresql_manager import PostgreSQLManager
from ..utils.config import config
import garth
from ..services.garmin.client import GarminClient
router = APIRouter()
logger = logging.getLogger(__name__)
def get_db():
db_manager = PostgreSQLManager(config.DATABASE_URL)
@@ -24,260 +22,35 @@ class GarminCredentials(BaseModel):
password: str
is_china: bool = False
class FitbitCredentials(BaseModel):
client_id: str
client_secret: str
class FitbitCallback(BaseModel):
callback_url: str
class GarminMFARequest(BaseModel):
verification_code: str
session_id: str
class AuthStatusResponse(BaseModel):
garmin: Optional[dict] = None
fitbit: Optional[dict] = None
class AuthStatusResponse(BaseModel):
garmin: Optional[dict] = None
fitbit: Optional[dict] = None
@router.post("/setup/load-consul-config")
async def load_consul_config(db: Session = Depends(get_db)):
"""
Load configuration from Consul and save it to the database.
It first tries to use tokens from Consul, if they are not present, it falls back to username/password login.
"""
consul_url = "http://consul.service.dc1.consul:8500/v1/kv/fitbit-garmin-sync/config"
try:
async with httpx.AsyncClient() as client:
response = await client.get(consul_url)
response.raise_for_status()
data = response.json()
if not (data and 'Value' in data[0]):
raise HTTPException(status_code=404, detail="Config not found in Consul")
config_value = base64.b64decode(data[0]['Value']).decode('utf-8')
config = json.loads(config_value)
if 'garmin' in config:
garmin_config = config['garmin']
from ..models.api_token import APIToken
from datetime import datetime
# Prefer tokens if available
if 'garth_oauth1_token' in garmin_config and 'garth_oauth2_token' in garmin_config:
token_record = db.query(APIToken).filter_by(token_type='garmin').first()
if not token_record:
token_record = APIToken(token_type='garmin')
db.add(token_record)
token_record.garth_oauth1_token = garmin_config['garth_oauth1_token']
token_record.garth_oauth2_token = garmin_config['garth_oauth2_token']
token_record.updated_at = datetime.now()
db.commit()
return {"status": "success", "message": "Garmin tokens from Consul have been saved."}
# Fallback to username/password login
elif 'username' in garmin_config and 'password' in garmin_config:
garmin_creds = GarminCredentials(**garmin_config)
garmin_client = GarminClient(garmin_creds.username, garmin_creds.password, garmin_creds.is_china)
status = garmin_client.login()
if status == "mfa_required":
return {"status": "mfa_required", "message": "Garmin login from Consul requires MFA. Please complete it manually."}
elif status != "success":
raise HTTPException(status_code=400, detail=f"Failed to login to Garmin with Consul credentials: {status}")
# TODO: Add Fitbit credentials handling
return {"status": "success", "message": "Configuration from Consul processed."}
except httpx.RequestError as e:
raise HTTPException(status_code=500, detail=f"Failed to connect to Consul: {e}")
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"An error occurred: {e}")
@router.get("/setup/auth-status", response_model=AuthStatusResponse)
async def get_auth_status(db: Session = Depends(get_db)):
from ..models.api_token import APIToken
garmin_status = {}
fitbit_status = {}
# Garmin Status
garmin_token = db.query(APIToken).filter_by(token_type='garmin').first()
if garmin_token:
garmin_status = {
"token_stored": True,
"authenticated": garmin_token.garth_oauth1_token is not None and garmin_token.garth_oauth2_token is not None,
"garth_oauth1_token_exists": garmin_token.garth_oauth1_token is not None,
"garth_oauth2_token_exists": garmin_token.garth_oauth2_token is not None,
"mfa_state_exists": garmin_token.mfa_state is not None,
"mfa_expires_at": garmin_token.mfa_expires_at,
"last_used": garmin_token.last_used,
"updated_at": garmin_token.updated_at,
"username": "N/A", # Placeholder, username is not stored in APIToken
"is_china": False # Placeholder
}
else:
garmin_status = {
"token_stored": False,
"authenticated": False
}
# Fitbit Status (Existing logic, might need adjustment if Fitbit tokens are stored differently)
fitbit_token = db.query(APIToken).filter_by(token_type='fitbit').first()
if fitbit_token:
fitbit_status = {
"token_stored": True,
"authenticated": fitbit_token.access_token is not None,
"client_id": fitbit_token.access_token[:10] + "..." if fitbit_token.access_token else "N/A",
"expires_at": fitbit_token.expires_at,
"last_used": fitbit_token.last_used,
"updated_at": fitbit_token.updated_at
}
else:
fitbit_status = {
"token_stored": False,
"authenticated": False
}
return AuthStatusResponse(
garmin=garmin_status,
fitbit=fitbit_status
)
@router.post("/setup/garmin")
async def save_garmin_credentials(credentials: GarminCredentials, db: Session = Depends(get_db)):
from ..utils.helpers import setup_logger
logger = setup_logger(__name__)
logger.info(f"Received Garmin credentials for user: {credentials.username}, is_china: {credentials.is_china}")
def save_garmin_credentials(credentials: GarminCredentials, db: Session = Depends(get_db)):
logger.info(f"Received Garmin credentials for user: {credentials.username}")
garmin_client = GarminClient(credentials.username, credentials.password, credentials.is_china)
logger.debug("GarminClient instance created successfully")
logger.debug("Attempting to log in to Garmin")
# Check the status returned directly
status = garmin_client.login()
status = garmin_client.login(db)
if status == "mfa_required":
# Hardcode the session_id as 'garmin' since you use a single record in APIToken
return JSONResponse(
status_code=200,
content={
"status": "mfa_required",
"message": "MFA Required",
"session_id": "garmin"
}
)
return JSONResponse(status_code=202, content={"status": "mfa_required", "message": "MFA code required."})
return JSONResponse(
status_code=200,
content={"status": "success", "message": "Logged in!"}
)
return JSONResponse(status_code=200, content={"status": "success", "message": "Logged in and tokens saved."})
@router.post("/setup/garmin/mfa")
async def complete_garmin_mfa(mfa_request: GarminMFARequest, db: Session = Depends(get_db)):
from ..utils.helpers import setup_logger
logger = setup_logger(__name__)
def complete_garmin_mfa(mfa_request: GarminMFARequest, db: Session = Depends(get_db)):
logger.info(f"Received MFA verification code: {'*' * len(mfa_request.verification_code)}")
try:
logger.info(f"Received MFA verification code for session {mfa_request.session_id}: {'*' * len(mfa_request.verification_code)}")
garmin_client = GarminClient()
success = garmin_client.handle_mfa(db, mfa_request.verification_code)
try:
garmin_client = GarminClient()
logger.debug(f"Attempting to handle MFA for session: {mfa_request.session_id}")
if success:
return JSONResponse(status_code=200, content={"status": "success", "message": "MFA verification successful, tokens saved."})
else:
raise HTTPException(status_code=400, detail="MFA verification failed.")
success = garmin_client.handle_mfa(mfa_request.verification_code, session_id=mfa_request.session_id)
if success:
logger.info(f"MFA verification completed successfully for session: {mfa_request.session_id}")
return JSONResponse(
status_code=200,
content={"status": "success", "message": "MFA verification completed successfully"}
)
else:
logger.error(f"MFA verification failed for session: {mfa_request.session_id}")
return JSONResponse(
status_code=400,
content={"status": "error", "message": "MFA verification failed"}
)
except Exception as e:
logger.error(f"MFA verification failed for session {mfa_request.session_id} with exception: {str(e)}")
logger.error(f"Exception type: {type(e).__name__}")
logger.error(f"Exception details: {repr(e)}")
logger.error(f"Full traceback: {traceback.format_exc()}")
return JSONResponse(
status_code=500,
content={"status": "error", "message": f"MFA verification failed: {str(e)}"}
)
except Exception as outer_error:
logger.error(f"Unexpected error in complete_garmin_mfa: {str(outer_error)}")
logger.error(f"Full traceback: {traceback.format_exc()}")
return JSONResponse(
status_code=500,
content={"status": "error", "message": f"Unexpected error: {str(outer_error)}"}
)
@router.post("/setup/fitbit")
async def save_fitbit_credentials(credentials: FitbitCredentials, db: Session = Depends(get_db)):
return {
"status": "success",
"auth_url": "https://www.fitbit.com/oauth2/authorize?...",
"message": "Fitbit credentials saved, please visit auth_url to authorize"
}
@router.post("/setup/fitbit/callback")
async def fitbit_callback(callback_data: FitbitCallback, db: Session = Depends(get_db)):
return {"status": "success", "message": "Fitbit OAuth flow completed successfully"}
@router.post("/setup/garmin/test-token")
async def test_garmin_token(db: Session = Depends(get_db)):
from ..models.api_token import APIToken
from garth.auth_tokens import OAuth1Token, OAuth2Token
import json
token_record = db.query(APIToken).filter_by(token_type='garmin').first()
if not token_record or not token_record.garth_oauth1_token or not token_record.garth_oauth2_token:
raise HTTPException(status_code=404, detail="Garmin token not found or incomplete.")
try:
from ..utils.helpers import setup_logger
logger = setup_logger(__name__)
logger.info("garth_oauth1_token from DB: %s", token_record.garth_oauth1_token)
logger.info("Type of garth_oauth1_token: %s", type(token_record.garth_oauth1_token))
logger.info("garth_oauth2_token from DB: %s", token_record.garth_oauth2_token)
logger.info("Type of garth_oauth2_token: %s", type(token_record.garth_oauth2_token))
if not token_record.garth_oauth1_token or not token_record.garth_oauth2_token:
raise HTTPException(status_code=400, detail="OAuth1 or OAuth2 token is empty.")
import garth
# Parse JSON to dictionaries
oauth1_dict = json.loads(token_record.garth_oauth1_token)
oauth2_dict = json.loads(token_record.garth_oauth2_token)
# Convert to proper token objects
garth.client.oauth1_token = OAuth1Token(**oauth1_dict)
garth.client.oauth2_token = OAuth2Token(**oauth2_dict)
# Also configure the domain if present
if oauth1_dict.get('domain'):
garth.configure(domain=oauth1_dict['domain'])
profile_info = garth.UserProfile.get()
return profile_info
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"Failed to test Garmin token: {e}")
logger.error(f"MFA verification failed with exception: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"MFA verification failed: {str(e)}")

View File

@@ -1,36 +1,48 @@
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from typing import List, Optional
from sqlalchemy.orm import Session
from ..services.postgresql_manager import PostgreSQLManager
from ..utils.config import config
from ..models.activity import Activity
from ..models.sync_log import SyncLog
from datetime import datetime
router = APIRouter()
def get_db():
db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session:
yield session
class SyncLogResponse(BaseModel):
id: int
operation: str
status: str
message: Optional[str]
start_time: str
end_time: Optional[str]
message: Optional[str] = None
start_time: datetime
end_time: Optional[datetime] = None
records_processed: int
records_failed: int
class Config:
orm_mode = True
class StatusResponse(BaseModel):
total_weight_records: int
synced_weight_records: int
unsynced_weight_records: int
total_activities: int
downloaded_activities: int
recent_logs: List[SyncLogResponse]
@router.get("/status")
async def get_status():
# This would return the current sync status
# Implementation will connect with the services layer
return {
"total_weight_records": 100,
"synced_weight_records": 85,
"unsynced_weight_records": 15,
"total_activities": 50,
"downloaded_activities": 30,
"recent_logs": []
}
@router.get("/status", response_model=StatusResponse)
def get_status(db: Session = Depends(get_db)):
"""Returns the current sync status and recent logs."""
total_activities = db.query(Activity).count()
downloaded_activities = db.query(Activity).filter(Activity.download_status == 'downloaded').count()
recent_logs = db.query(SyncLog).order_by(SyncLog.start_time.desc()).limit(10).all()
return StatusResponse(
total_activities=total_activities,
downloaded_activities=downloaded_activities,
recent_logs=recent_logs
)

View File

@@ -1,11 +1,20 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from ..models.api_token import APIToken
from ..services.sync_app import SyncApp
from ..services.garmin.client import GarminClient
from ..services.postgresql_manager import PostgreSQLManager
from sqlalchemy.orm import Session
from ..utils.config import config
import logging
import json
import garth
from garth.auth_tokens import OAuth1Token, OAuth2Token
router = APIRouter()
logger = logging.getLogger(__name__)
class SyncActivityRequest(BaseModel):
days_back: int = 30
@@ -20,32 +29,50 @@ def get_db():
with db_manager.get_db_session() as session:
yield session
@router.post("/sync/weight", response_model=SyncResponse)
async def sync_weight(db: Session = Depends(get_db)):
# This would trigger the weight sync process
# Implementation will connect with the services layer
return {
"status": "started",
"message": "Weight sync process started",
"job_id": "weight-sync-12345"
}
def _load_and_verify_garth_session(db: Session):
"""Helper to load token from DB and verify session with Garmin."""
logger.info("Loading and verifying Garmin session...")
token_record = db.query(APIToken).filter_by(token_type='garmin').first()
if not (token_record and token_record.garth_oauth1_token and token_record.garth_oauth2_token):
raise HTTPException(status_code=401, detail="Garmin token not found.")
try:
oauth1_dict = json.loads(token_record.garth_oauth1_token)
oauth2_dict = json.loads(token_record.garth_oauth2_token)
domain = oauth1_dict.get('domain')
if domain:
garth.configure(domain=domain)
garth.client.oauth1_token = OAuth1Token(**oauth1_dict)
garth.client.oauth2_token = OAuth2Token(**oauth2_dict)
garth.UserProfile.get()
logger.info("Garth session verified.")
except Exception as e:
logger.error(f"Garth session verification failed: {e}", exc_info=True)
raise HTTPException(status_code=401, detail=f"Failed to authenticate with Garmin: {e}")
@router.post("/sync/activities", response_model=SyncResponse)
async def sync_activities(request: SyncActivityRequest, db: Session = Depends(get_db)):
# This would trigger the activity sync process
# Implementation will connect with the services layer
return {
"status": "started",
"message": "Activity sync process started",
"job_id": f"activity-sync-{request.days_back}"
}
def sync_activities(request: SyncActivityRequest, db: Session = Depends(get_db)):
_load_and_verify_garth_session(db)
garmin_client = GarminClient() # The client is now just a thin wrapper
sync_app = SyncApp(db_session=db, garmin_client=garmin_client)
result = sync_app.sync_activities(days_back=request.days_back)
return SyncResponse(
status=result.get("status", "completed_with_errors" if result.get("failed", 0) > 0 else "completed"),
message=f"Activity sync completed: {result.get('processed', 0)} processed, {result.get('failed', 0)} failed",
job_id=f"activity-sync-{datetime.now().strftime('%Y%m%d%H%M%S')}"
)
@router.post("/sync/metrics", response_model=SyncResponse)
async def sync_metrics(db: Session = Depends(get_db)):
# This would trigger the health metrics sync process
# Implementation will connect with the services layer
return {
"status": "started",
"message": "Health metrics sync process started",
"job_id": "metrics-sync-12345"
}
def sync_metrics(db: Session = Depends(get_db)):
_load_and_verify_garth_session(db)
garmin_client = GarminClient()
sync_app = SyncApp(db_session=db, garmin_client=garmin_client)
result = sync_app.sync_health_metrics()
return SyncResponse(
status=result.get("status", "completed_with_errors" if result.get("failed", 0) > 0 else "completed"),
message=f"Health metrics sync completed: {result.get('processed', 0)} processed, {result.get('failed', 0)} failed",
job_id=f"metrics-sync-{datetime.now().strftime('%Y%m%d%H%M%S')}"
)