before claude fix #1
This commit is contained in:
0
FitnessSync/backend/src/services/garmin/__init__.py
Normal file
0
FitnessSync/backend/src/services/garmin/__init__.py
Normal file
217
FitnessSync/backend/src/services/garmin/auth.py
Normal file
217
FitnessSync/backend/src/services/garmin/auth.py
Normal file
@@ -0,0 +1,217 @@
|
||||
import garth
|
||||
import garminconnect
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
import json
|
||||
import traceback
|
||||
|
||||
from src.utils.helpers import setup_logger
|
||||
from src.models.api_token import APIToken
|
||||
from src.services.postgresql_manager import PostgreSQLManager
|
||||
from src.utils.config import config
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
class AuthMixin:
|
||||
def login(self):
|
||||
"""Login to Garmin Connect with proper token handling."""
|
||||
logger.info(f"Starting login process for Garmin user: {self.username}")
|
||||
try:
|
||||
logger.debug(f"Attempting garth login for user: {self.username}")
|
||||
garth.login(self.username, self.password, return_on_mfa=True)
|
||||
logger.debug(f"Successfully completed garth authentication for: {self.username}")
|
||||
|
||||
logger.debug(f"Creating Garmin Connect client for user: {self.username}")
|
||||
self.garmin_client = garminconnect.Garmin(self.username, self.password)
|
||||
self.garmin_client.garth = garth.client
|
||||
logger.debug(f"Successfully created Garmin Connect client for user: {self.username}")
|
||||
|
||||
self.is_connected = True
|
||||
logger.info(f"Setting is_connected to True for user: {self.username}")
|
||||
|
||||
self.save_tokens()
|
||||
logger.info(f"Successfully logged in to Garmin Connect as {self.username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error logging in to Garmin Connect: {str(e)}")
|
||||
logger.error(f"Exception type: {type(e).__name__}")
|
||||
|
||||
error_str = str(e).lower()
|
||||
if "mfa" in error_str or "2fa" in error_str or "unauthorized" in error_str:
|
||||
logger.warning(f"Multi-factor authentication likely required for {self.username}")
|
||||
logger.debug(f"Detected MFA indicator in error message: {error_str}")
|
||||
raise Exception("MFA Required: Please provide verification code")
|
||||
|
||||
logger.error(f"Full traceback: {traceback.format_exc()}")
|
||||
raise e
|
||||
|
||||
def save_tokens(self):
|
||||
"""Save garth tokens to be used later."""
|
||||
logger.info(f"Starting token saving process for user: {self.username}")
|
||||
try:
|
||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
||||
|
||||
with db_manager.get_db_session() as session:
|
||||
token_record = session.query(APIToken).filter(APIToken.token_type == 'garmin').first()
|
||||
|
||||
if not token_record:
|
||||
token_record = APIToken(token_type='garmin')
|
||||
session.add(token_record)
|
||||
|
||||
oauth1_token = getattr(garth.client, 'oauth1_token', None)
|
||||
oauth2_token = getattr(garth.client, 'oauth2_token', None)
|
||||
|
||||
if oauth1_token:
|
||||
try:
|
||||
token_dict = oauth1_token.__dict__ if hasattr(oauth1_token, '__dict__') else str(oauth1_token)
|
||||
token_record.garth_oauth1_token = json.dumps(token_dict, default=str)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not serialize OAuth1 token for user {self.username}: {e}")
|
||||
|
||||
if oauth2_token:
|
||||
try:
|
||||
token_dict = oauth2_token.__dict__ if hasattr(oauth2_token, '__dict__') else str(oauth2_token)
|
||||
token_record.garth_oauth2_token = json.dumps(token_dict, default=str)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not serialize OAuth2 token for user {self.username}: {e}")
|
||||
|
||||
session.commit()
|
||||
logger.info(f"Garmin tokens saved successfully for user: {self.username}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving garth tokens for user {self.username}: {str(e)}")
|
||||
raise e
|
||||
|
||||
def load_tokens(self):
|
||||
"""Load garth tokens to resume a session."""
|
||||
logger.info(f"Starting token loading process for user: {self.username}")
|
||||
try:
|
||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
||||
|
||||
with db_manager.get_db_session() as session:
|
||||
try:
|
||||
token_record = session.query(APIToken).filter(APIToken.token_type == 'garmin').first()
|
||||
except Exception as db_error:
|
||||
logger.info(f"No existing Garmin tokens found for user {self.username} or table doesn't exist: {db_error}")
|
||||
return False
|
||||
|
||||
if not token_record or (not token_record.garth_oauth1_token and not token_record.garth_oauth2_token):
|
||||
logger.info(f"No Garmin token record found in database for user: {self.username}")
|
||||
return False
|
||||
|
||||
if token_record.garth_oauth1_token:
|
||||
try:
|
||||
oauth1_data = json.loads(token_record.garth_oauth1_token)
|
||||
setattr(garth.client, 'oauth1_token', oauth1_data)
|
||||
logger.info(f"Successfully restored OAuth1 token for user: {self.username}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not restore OAuth1 token for user {self.username}: {e}")
|
||||
|
||||
if token_record.garth_oauth2_token:
|
||||
try:
|
||||
oauth2_data = json.loads(token_record.garth_oauth2_token)
|
||||
setattr(garth.client, 'oauth2_token', oauth2_data)
|
||||
logger.info(f"Successfully restored OAuth2 token for user: {self.username}")
|
||||
|
||||
self.garmin_client = garminconnect.Garmin(self.username, self.password)
|
||||
self.garmin_client.garth = garth.client
|
||||
self.is_connected = True
|
||||
logger.debug(f"Successfully created Garmin Connect client for user {self.username} with restored session")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not restore OAuth2 token for user {self.username}: {e}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading garth tokens for user {self.username}: {str(e)}")
|
||||
return False
|
||||
|
||||
def initiate_mfa(self, username: str = None):
|
||||
"""Initiate the MFA process and return session data."""
|
||||
user_identifier = username if username else self.username
|
||||
logger.info(f"Initiating MFA process for Garmin user: {user_identifier}")
|
||||
mfa_session_id = str(uuid4())
|
||||
|
||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
||||
|
||||
with db_manager.get_db_session() as session:
|
||||
token_record = session.query(APIToken).filter(APIToken.token_type == 'garmin').first()
|
||||
|
||||
if not token_record:
|
||||
token_record = APIToken(token_type='garmin')
|
||||
session.add(token_record)
|
||||
|
||||
token_record.mfa_session_id = mfa_session_id
|
||||
resume_data = {
|
||||
'username': user_identifier,
|
||||
'password': self.password,
|
||||
'is_china': self.is_china
|
||||
}
|
||||
token_record.mfa_resume_data = json.dumps(resume_data)
|
||||
token_record.mfa_expires_at = datetime.now() + timedelta(minutes=10)
|
||||
|
||||
session.commit()
|
||||
|
||||
logger.info(f"MFA session initiated for user: {user_identifier}, session ID: {mfa_session_id}")
|
||||
return mfa_session_id
|
||||
|
||||
def handle_mfa(self, verification_code: str, session_id: str = None):
|
||||
"""Handle the MFA process by completing authentication with the verification code."""
|
||||
logger.info(f"Starting MFA completion process with session ID: {session_id}")
|
||||
|
||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
||||
|
||||
with db_manager.get_db_session() as session:
|
||||
token_record = session.query(APIToken).filter(
|
||||
APIToken.token_type == 'garmin',
|
||||
APIToken.mfa_session_id == session_id
|
||||
).first()
|
||||
|
||||
if not token_record:
|
||||
raise Exception("No pending MFA authentication for this session.")
|
||||
|
||||
if token_record.mfa_expires_at and datetime.now() > token_record.mfa_expires_at:
|
||||
self.cleanup_mfa_session(token_record, session)
|
||||
raise Exception("MFA verification code has expired.")
|
||||
|
||||
try:
|
||||
resume_data = json.loads(token_record.mfa_resume_data)
|
||||
self.username = resume_data.get('username')
|
||||
self.password = resume_data.get('password')
|
||||
|
||||
if resume_data.get('is_china', False):
|
||||
garth.configure(domain="garmin.cn")
|
||||
|
||||
try:
|
||||
garth.client.mfa_submit(verification_code)
|
||||
except AttributeError:
|
||||
garth.login(self.username, self.password, verification_code)
|
||||
|
||||
self.garmin_client = garminconnect.Garmin(self.username, self.password)
|
||||
self.garmin_client.garth = garth.client
|
||||
|
||||
try:
|
||||
profile = self.garmin_client.get_full_name()
|
||||
logger.info(f"Verified authentication for user: {profile}")
|
||||
except Exception as verify_error:
|
||||
logger.warning(f"Could not verify authentication for user {self.username}: {verify_error}")
|
||||
|
||||
self.is_connected = True
|
||||
self.save_tokens()
|
||||
|
||||
self.cleanup_mfa_session(token_record, session)
|
||||
|
||||
logger.info(f"Successfully completed MFA authentication for {self.username}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during MFA completion for user {self.username}: {e}")
|
||||
self.cleanup_mfa_session(token_record, session)
|
||||
raise e
|
||||
|
||||
def cleanup_mfa_session(self, token_record, session):
|
||||
"""Clear out MFA session data from the token record."""
|
||||
token_record.mfa_session_id = None
|
||||
token_record.mfa_resume_data = None
|
||||
token_record.mfa_expires_at = None
|
||||
session.commit()
|
||||
logger.debug("MFA session data cleaned up.")
|
||||
39
FitnessSync/backend/src/services/garmin/client.py
Normal file
39
FitnessSync/backend/src/services/garmin/client.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import garth
|
||||
from src.utils.helpers import setup_logger
|
||||
from .auth import AuthMixin
|
||||
from .data import DataMixin
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
class GarminClient(AuthMixin, DataMixin):
|
||||
def __init__(self, username: str = None, password: str = None, is_china: bool = False):
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.is_china = is_china
|
||||
self.garmin_client = None
|
||||
self.is_connected = False
|
||||
|
||||
logger.debug(f"Initializing GarminClient for user: {username}, is_china: {is_china}")
|
||||
|
||||
if is_china:
|
||||
logger.debug("Configuring garth for China domain")
|
||||
garth.configure(domain="garmin.cn")
|
||||
|
||||
if username and password:
|
||||
logger.info(f"Attempting to authenticate Garmin user: {username}")
|
||||
if not self.load_tokens():
|
||||
logger.info("No valid tokens found, attempting fresh login")
|
||||
self.login()
|
||||
else:
|
||||
logger.info("Successfully loaded existing tokens, skipping fresh login")
|
||||
else:
|
||||
logger.debug("No username/password provided during initialization")
|
||||
|
||||
def check_connection(self) -> bool:
|
||||
"""Check if the connection to Garmin is still valid."""
|
||||
try:
|
||||
profile = self.garmin_client.get_full_name() if self.garmin_client else None
|
||||
return profile is not None
|
||||
except:
|
||||
self.is_connected = False
|
||||
return False
|
||||
139
FitnessSync/backend/src/services/garmin/data.py
Normal file
139
FitnessSync/backend/src/services/garmin/data.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
from src.utils.helpers import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
class DataMixin:
|
||||
def upload_weight(self, weight: float, unit: str = 'kg', timestamp: datetime = None) -> bool:
|
||||
"""Upload weight entry to Garmin Connect."""
|
||||
if not self.is_connected:
|
||||
raise Exception("Not connected to Garmin Connect")
|
||||
|
||||
try:
|
||||
if not timestamp:
|
||||
timestamp = datetime.now()
|
||||
|
||||
try:
|
||||
result = self.garmin_client.add_body_composition(
|
||||
timestamp=timestamp,
|
||||
weight=weight
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
result = self.garmin_client.add_body_composition(
|
||||
timestamp=timestamp.isoformat(),
|
||||
weight=weight
|
||||
)
|
||||
except Exception:
|
||||
result = self.garmin_client.add_body_composition(
|
||||
timestamp=timestamp.strftime('%Y-%m-%d'),
|
||||
weight=weight
|
||||
)
|
||||
|
||||
logger.info(f"Successfully uploaded weight: {weight} {unit} at {timestamp}")
|
||||
return result is not None
|
||||
except Exception as e:
|
||||
logger.error(f"Error uploading weight to Garmin: {str(e)}")
|
||||
if "401" in str(e) or "unauthorized" in str(e).lower():
|
||||
logger.error("Authentication failed - need to re-authenticate")
|
||||
raise Exception("Authentication expired, needs re-authentication")
|
||||
raise e
|
||||
|
||||
def get_activities(self, start_date: str, end_date: str = None, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
"""Fetch activity list from Garmin Connect."""
|
||||
if not self.is_connected:
|
||||
raise Exception("Not connected to Garmin Connect")
|
||||
|
||||
try:
|
||||
if not end_date:
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
activities = self.garmin_client.get_activities(start_date, end_date)
|
||||
logger.info(f"Fetched {len(activities)} activities from Garmin")
|
||||
return activities
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching activities from Garmin: {str(e)}")
|
||||
raise e
|
||||
|
||||
def download_activity(self, activity_id: str, file_type: str = 'tcx') -> Optional[bytes]:
|
||||
"""Download activity file from Garmin Connect and return its content."""
|
||||
if not self.is_connected:
|
||||
raise Exception("Not connected to Garmin Connect")
|
||||
|
||||
try:
|
||||
file_content = self.garmin_client.get_activity_details(activity_id)
|
||||
logger.info(f"Downloaded activity {activity_id} as {file_type} format")
|
||||
return file_content if file_content else b""
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading activity {activity_id} from Garmin: {str(e)}")
|
||||
raise e
|
||||
|
||||
def get_heart_rates(self, start_date: str, end_date: str = None) -> Dict[str, Any]:
|
||||
"""Fetch heart rate data from Garmin Connect."""
|
||||
if not self.is_connected:
|
||||
raise Exception("Not connected to Garmin Connect")
|
||||
|
||||
try:
|
||||
if not end_date:
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
heart_rates = self.garmin_client.get_heart_rates(start_date, end_date)
|
||||
logger.info(f"Fetched heart rate data from Garmin for {start_date} to {end_date}")
|
||||
return heart_rates
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching heart rate data from Garmin: {str(e)}")
|
||||
raise e
|
||||
|
||||
def get_sleep_data(self, start_date: str, end_date: str = None) -> Dict[str, Any]:
|
||||
"""Fetch sleep data from Garmin Connect."""
|
||||
if not self.is_connected:
|
||||
raise Exception("Not connected to Garmin Connect")
|
||||
|
||||
try:
|
||||
if not end_date:
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
sleep_data = self.garmin_client.get_sleep_data(start_date, end_date)
|
||||
logger.info(f"Fetched sleep data from Garmin for {start_date} to {end_date}")
|
||||
return sleep_data
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching sleep data from Garmin: {str(e)}")
|
||||
raise e
|
||||
|
||||
def get_steps_data(self, start_date: str, end_date: str = None) -> Dict[str, Any]:
|
||||
"""Fetch steps data from Garmin Connect."""
|
||||
if not self.is_connected:
|
||||
raise Exception("Not connected to Garmin Connect")
|
||||
|
||||
try:
|
||||
if not end_date:
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
steps_data = self.garmin_client.get_steps_data(start_date, end_date)
|
||||
logger.info(f"Fetched steps data from Garmin for {start_date} to {end_date}")
|
||||
return steps_data
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching steps data from Garmin: {str(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:
|
||||
if not end_date:
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
metrics = {
|
||||
'heart_rates': self.get_heart_rates(start_date, end_date),
|
||||
'sleep_data': self.get_sleep_data(start_date, end_date),
|
||||
'steps_data': self.get_steps_data(start_date, end_date),
|
||||
}
|
||||
|
||||
logger.info(f"Fetched all metrics from Garmin for {start_date} to {end_date}")
|
||||
return metrics
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching all metrics from Garmin: {str(e)}")
|
||||
raise e
|
||||
Reference in New Issue
Block a user