Files
FitTrack2/FitnessSync/backend/src/services/fitbit_client.py
2026-01-01 07:14:18 -08:00

152 lines
7.4 KiB
Python

import fitbit
from fitbit import exceptions
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
import logging
import time
from ..utils.helpers import setup_logger
logger = setup_logger(__name__)
class FitbitClient:
def __init__(self, client_id: str, client_secret: str, access_token: str = None, refresh_token: str = None, redirect_uri: str = None):
self.client_id = client_id
self.client_secret = client_secret
self.access_token = access_token
self.refresh_token = refresh_token
self.redirect_uri = redirect_uri
self.fitbit = None
# Initialize Fitbit class if we have enough info, or just for auth flow
# The example initializes it immediately
if client_id and client_secret:
self.fitbit = fitbit.Fitbit(
client_id,
client_secret,
access_token=access_token,
refresh_token=refresh_token,
redirect_uri=redirect_uri,
timeout=10
)
def get_authorization_url(self, redirect_uri: str = None) -> str:
"""Generate authorization URL for Fitbit OAuth flow."""
# Update internal redirect_uri if provided
if redirect_uri:
self.redirect_uri = redirect_uri
# Re-init or update client? Fitbit class uses it in init.
# It seems simpler to recreate the instance or update the client manually if supported.
# But based on the example, we can just init with it.
self.fitbit = fitbit.Fitbit(
self.client_id,
self.client_secret,
redirect_uri=redirect_uri,
timeout=10
)
# The example calls self.fitbit.client.authorize_token_url()
# Note: The fitbit library might default to certain scopes or we need to pass them?
# The example used: url, _ = self.fitbit.client.authorize_token_url()
# But we need scopes.
scope = ['weight', 'nutrition', 'activity', 'sleep', 'heartrate', 'profile']
# The underlying client is oauthlib.oauth2.WebApplicationClient usually
auth_url, _ = self.fitbit.client.authorize_token_url(scope=scope)
logger.info(f"Generated Fitbit authorization URL: {auth_url}")
return auth_url
def exchange_code_for_token(self, code: str, redirect_uri: str = None) -> Dict[str, Any]:
"""Exchange authorization code for access and refresh tokens."""
# If redirect_uri is provided here, ensure we are using a client configured with it
if redirect_uri and redirect_uri != self.redirect_uri:
self.fitbit = fitbit.Fitbit(
self.client_id,
self.client_secret,
redirect_uri=redirect_uri,
timeout=10
)
logger.info(f"Exchanging authorization code for tokens")
# The example: self.fitbit.client.fetch_access_token(code)
# It updates the internal token automatically.
token = self.fitbit.client.fetch_access_token(code)
return token
def get_weight_logs(self, start_date: str, end_date: str = None) -> List[Dict[str, Any]]:
"""Fetch weight logs from Fitbit API."""
if not self.fitbit:
raise Exception("Fitbit client not authenticated")
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
try:
print(f"Making request to Fitbit API: get_bodyweight(base_date={start_date}, end_date={end_date})", flush=True)
# get_bodyweight returns {'weight': [...]}
weight_logs = self.fitbit.get_bodyweight(
base_date=start_date,
end_date=end_date
)
logs = weight_logs.get('weight', [])
print(f"Fitbit Response: Success. Fetched {len(logs)} weight entries.", flush=True)
return logs
except exceptions.HTTPTooManyRequests as e:
retry_after = e.retry_after_secs if hasattr(e, 'retry_after_secs') else 'unknown'
print(f"Fitbit API Rate Limit Hit! Retry-After: {retry_after} seconds.", flush=True)
if not retry_after or retry_after == 'unknown':
if hasattr(e, 'response') and e.response is not None:
retry_after = e.response.headers.get('Retry-After', 'unknown')
print(f"Rate Limited. Recommended wait: {retry_after}", flush=True)
raise e
except KeyError as e:
# Handle specific KeyError from fitbit library when parsing 429 response
if str(e).strip("'\"") == 'retry-after':
print("Fitbit Library KeyError 'retry-after' detected. This is a Rate Limit event.", flush=True)
# Raise as TooManyRequests manually
# We don't have the response object easily here unless we dig into stack,
# so we assume a safe default wait time.
print("Rate Limited (inferred). Recommended wait: 60 seconds (default).", flush=True)
# Create a mock exception or just raise HTTPTooManyRequests with limited info
# The library's exception requires a response object in init usually, but let's try to simulate or just raise generic
# Actually, better to raise the library's exception if possible, or our own wrappers.
# Let's just re-raise as the libraries exception but monkey-patch the retry_after_secs if possible?
# No, just raise it and let the caller handle it.
# Since we can't easily construct the proper exception without a response object,
# we'll raise a new HTTPTooManyRequests with a valid retry_after_secs if we can, or just let sync.py handle generic error?
# Sync.py catches Exception.
# Better: Log clearly and raise a clean error that implies rate limit so user sees it.
# AND if we want to auto-retry, we need to signal that.
# Let's assume 60s wait.
# We can construct a dummy object to hold the retry value if needed,
# but for now let's just print and raise.
# To make sync.py sleep, we can modify sync.py.
# Or here, we can sleep? No, sync.py controls the loop.
pass
# Fall through to generic Exception handler which prints it?
# No, we want to customize the message.
raise Exception("Fitbit Rate Limit Hit (Library Error). Retry-After: 60s") from e
raise e
except Exception as e:
print(f"Error fetching weight logs from Fitbit: {str(e)}", flush=True)
if hasattr(e, 'response') and e.response is not None:
print(f"Fitbit API Error Response: {e.response.text}", flush=True)
print(f"Fitbit API Error Headers: {e.response.headers}", flush=True)
raise e
def refresh_access_token(self) -> Dict[str, str]:
"""Refresh the Fitbit access token."""
# Implementation for token refresh
logger.info("Refreshing Fitbit access token")
# Return mock response for now
return {
"access_token": "new_mock_access_token",
"expires_at": (datetime.now() + timedelta(hours=1)).isoformat()
}