mirror of
https://github.com/sstent/FitTrack_GarminSync.git
synced 2026-03-24 05:45:23 +00:00
feat: Implement single sync job management and progress tracking
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
import logging
|
||||
|
||||
from ..dependencies import get_central_db_service, get_garmin_auth_service, get_garmin_client_service
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from ..dependencies import (
|
||||
get_central_db_service,
|
||||
get_garmin_auth_service,
|
||||
get_garmin_client_service,
|
||||
)
|
||||
from ..schemas import GarminLoginRequest, GarminLoginResponse
|
||||
from ..services.central_db_service import CentralDBService
|
||||
from ..services.garmin_auth_service import GarminAuthService
|
||||
@@ -12,7 +16,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/login", response_model=GarminLoginResponse, status_code=status.HTTP_200_OK)
|
||||
|
||||
@router.post(
|
||||
"/login", response_model=GarminLoginResponse, status_code=status.HTTP_200_OK
|
||||
)
|
||||
async def garmin_login(
|
||||
request: GarminLoginRequest,
|
||||
garmin_auth_service: GarminAuthService = Depends(get_garmin_auth_service),
|
||||
@@ -32,55 +39,74 @@ async def garmin_login(
|
||||
# Update GarminClientService with existing credentials
|
||||
garmin_client_service.update_credentials(
|
||||
existing_credentials.garmin_username,
|
||||
existing_credentials.garmin_password_plaintext
|
||||
existing_credentials.garmin_password_plaintext,
|
||||
)
|
||||
|
||||
# Check if already authenticated or if session is still valid
|
||||
if garmin_client_service.is_authenticated() and garmin_client_service.check_session_validity():
|
||||
logger.info(f"Garmin client already authenticated and session valid for {existing_credentials.garmin_username}. Reusing session.")
|
||||
if (
|
||||
garmin_client_service.is_authenticated()
|
||||
and garmin_client_service.check_session_validity()
|
||||
):
|
||||
logger.info(
|
||||
f"Garmin client already authenticated and session valid for "
|
||||
f"{existing_credentials.garmin_username}. Reusing session."
|
||||
)
|
||||
return GarminLoginResponse(message="Garmin account linked successfully.")
|
||||
else:
|
||||
logger.info(f"Garmin client not authenticated or session invalid for {existing_credentials.garmin_username}. Attempting to re-authenticate with existing credentials.")
|
||||
if garmin_client_service.authenticate(): # Only authenticate if not already valid
|
||||
logger.info(f"Successfully re-authenticated Garmin client with existing credentials for {existing_credentials.garmin_username}.")
|
||||
return GarminLoginResponse(message="Garmin account linked successfully.")
|
||||
logger.info(
|
||||
f"Garmin client not authenticated or session invalid for "
|
||||
f"{existing_credentials.garmin_username}. Attempting to re-authenticate "
|
||||
"with existing credentials."
|
||||
)
|
||||
if (
|
||||
garmin_client_service.authenticate()
|
||||
): # Only authenticate if not already valid
|
||||
logger.info(
|
||||
f"Successfully re-authenticated Garmin client with existing "
|
||||
f"credentials for {existing_credentials.garmin_username}."
|
||||
)
|
||||
return GarminLoginResponse(
|
||||
message="Garmin account linked successfully."
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Failed to re-authenticate with existing Garmin credentials for {existing_credentials.garmin_username}. Proceeding with fresh login attempt.")
|
||||
logger.warning(
|
||||
f"Failed to re-authenticate with existing Garmin credentials for "
|
||||
f"{existing_credentials.garmin_username}. Proceeding with fresh login attempt."
|
||||
)
|
||||
else:
|
||||
logger.info(f"No existing Garmin credentials found for user {user_id}. Proceeding with fresh login.")
|
||||
logger.info(
|
||||
f"No existing Garmin credentials found for user {user_id}. Proceeding with fresh login."
|
||||
)
|
||||
|
||||
# If no existing credentials, or existing credentials failed, perform a fresh login
|
||||
garmin_credentials = await garmin_auth_service.initial_login(
|
||||
request.username,
|
||||
request.password
|
||||
request.username, request.password
|
||||
)
|
||||
|
||||
if not garmin_credentials:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Garmin credentials provided."
|
||||
detail="Invalid Garmin credentials provided.",
|
||||
)
|
||||
|
||||
# Store/Update credentials in CentralDB after successful fresh login
|
||||
if existing_credentials:
|
||||
updated_credentials = await central_db_service.update_garmin_credentials(
|
||||
user_id,
|
||||
garmin_credentials.model_dump()
|
||||
user_id, garmin_credentials.model_dump()
|
||||
)
|
||||
if not updated_credentials:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update Garmin credentials in CentralDB."
|
||||
detail="Failed to update Garmin credentials in CentralDB.",
|
||||
)
|
||||
else:
|
||||
created_credentials = await central_db_service.create_garmin_credentials(
|
||||
user_id,
|
||||
garmin_credentials.model_dump()
|
||||
user_id, garmin_credentials.model_dump()
|
||||
)
|
||||
if not created_credentials:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to store Garmin credentials in CentralDB."
|
||||
detail="Failed to store Garmin credentials in CentralDB.",
|
||||
)
|
||||
|
||||
return GarminLoginResponse(message="Garmin account linked successfully.")
|
||||
|
||||
@@ -1,47 +1,57 @@
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
|
||||
|
||||
from ..dependencies import (
|
||||
get_current_user,
|
||||
get_garmin_activity_service,
|
||||
get_garmin_health_service, # Added this line
|
||||
get_garmin_workout_service,
|
||||
get_sync_status_service,
|
||||
)
|
||||
from ..jobs import SyncJob, job_store
|
||||
from ..models.sync_job import SyncJob
|
||||
from ..schemas import ActivitySyncRequest, User, WorkoutUploadRequest
|
||||
from ..services.garmin_activity_service import GarminActivityService
|
||||
from ..services.garmin_health_service import GarminHealthService
|
||||
from ..services.garmin_workout_service import GarminWorkoutService
|
||||
from ..services.sync_status_service import SyncStatusService
|
||||
from ..services.sync_manager import current_sync_job_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/garmin/activities", response_model=SyncJob, status_code=202)
|
||||
|
||||
@router.post("/garmin/activities", status_code=202)
|
||||
async def trigger_garmin_activity_sync(
|
||||
request: ActivitySyncRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
garmin_activity_service: GarminActivityService = Depends(get_garmin_activity_service),
|
||||
garmin_activity_service: GarminActivityService = Depends(
|
||||
get_garmin_activity_service
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
max_activities_to_sync: Optional[int] = 10, # Default to 10 activities
|
||||
max_activities_to_sync: Optional[int] = 10, # Default to 10 activities
|
||||
):
|
||||
"""
|
||||
Trigger Garmin Connect Activity Synchronization
|
||||
"""
|
||||
job = job_store.create_job()
|
||||
if await current_sync_job_manager.is_sync_active():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A synchronization is already in progress. Please wait or check status.",
|
||||
)
|
||||
await current_sync_job_manager.start_sync(job_type="activities")
|
||||
|
||||
background_tasks.add_task(
|
||||
garmin_activity_service.sync_activities_in_background,
|
||||
job.id,
|
||||
current_user.user_id,
|
||||
current_sync_job_manager,
|
||||
request.force_resync,
|
||||
request.start_date,
|
||||
request.end_date,
|
||||
max_activities_to_sync # Pass the new parameter
|
||||
max_activities_to_sync, # Pass the new parameter
|
||||
)
|
||||
|
||||
return job
|
||||
return {"message": "Activity synchronization initiated successfully."}
|
||||
|
||||
@router.post("/garmin/workouts", response_model=SyncJob, status_code=202)
|
||||
|
||||
@router.post("/garmin/workouts", status_code=202)
|
||||
async def upload_garmin_workout(
|
||||
request: WorkoutUploadRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
@@ -51,31 +61,51 @@ async def upload_garmin_workout(
|
||||
"""
|
||||
Upload a workout to Garmin Connect
|
||||
"""
|
||||
job = job_store.create_job()
|
||||
if await current_sync_job_manager.is_sync_active():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A synchronization is already in progress. Please wait or check status.",
|
||||
)
|
||||
await current_sync_job_manager.start_sync(job_type="workouts")
|
||||
|
||||
background_tasks.add_task(
|
||||
garmin_workout_service.upload_workout_in_background,
|
||||
job.id,
|
||||
current_user.user_id,
|
||||
current_sync_job_manager,
|
||||
request.workout_id,
|
||||
)
|
||||
|
||||
return job
|
||||
return {"message": "Workout synchronization initiated successfully."}
|
||||
|
||||
@router.get("/status/{job_id}", response_model=List[SyncJob], status_code=200)
|
||||
async def get_sync_status(
|
||||
job_id: UUID,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
sync_status_service: SyncStatusService = Depends(get_sync_status_service),
|
||||
|
||||
@router.post("/garmin/health", status_code=202)
|
||||
async def trigger_garmin_health_sync(
|
||||
background_tasks: BackgroundTasks,
|
||||
garmin_health_service: GarminHealthService = Depends(get_garmin_health_service),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Retrieve the status of synchronization jobs.
|
||||
Trigger Garmin Connect Health Metrics Synchronization
|
||||
"""
|
||||
sync_jobs = sync_status_service.get_sync_jobs(
|
||||
job_id=job_id,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
if await current_sync_job_manager.is_sync_active():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A synchronization is already in progress. Please wait or check status.",
|
||||
)
|
||||
await current_sync_job_manager.start_sync(job_type="health")
|
||||
|
||||
background_tasks.add_task(
|
||||
garmin_health_service.sync_health_metrics_in_background,
|
||||
current_user.user_id,
|
||||
current_sync_job_manager,
|
||||
)
|
||||
|
||||
return sync_jobs
|
||||
return {"message": "Health metrics synchronization initiated successfully."}
|
||||
|
||||
|
||||
@router.get("/garmin/sync/status", response_model=SyncJob, status_code=200)
|
||||
async def get_garmin_sync_status():
|
||||
"""
|
||||
Retrieve the current status of the single active synchronization job.
|
||||
"""
|
||||
return await current_sync_job_manager.get_current_sync_status()
|
||||
|
||||
@@ -6,7 +6,9 @@ import httpx
|
||||
from ... import errors
|
||||
from ...client import AuthenticatedClient, Client
|
||||
from ...models.activity import Activity
|
||||
from ...models.body_upload_activity_activities_post import BodyUploadActivityActivitiesPost
|
||||
from ...models.body_upload_activity_activities_post import (
|
||||
BodyUploadActivityActivitiesPost,
|
||||
)
|
||||
from ...models.http_validation_error import HTTPValidationError
|
||||
from ...types import Response
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ def _get_kwargs() -> dict[str, Any]:
|
||||
return _kwargs
|
||||
|
||||
|
||||
def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[Any]:
|
||||
def _parse_response(
|
||||
*, client: Union[AuthenticatedClient, Client], response: httpx.Response
|
||||
) -> Optional[Any]:
|
||||
if response.status_code == 200:
|
||||
return None
|
||||
|
||||
@@ -27,7 +29,9 @@ def _parse_response(*, client: Union[AuthenticatedClient, Client], response: htt
|
||||
return None
|
||||
|
||||
|
||||
def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[Any]:
|
||||
def _build_response(
|
||||
*, client: Union[AuthenticatedClient, Client], response: httpx.Response
|
||||
) -> Response[Any]:
|
||||
return Response(
|
||||
status_code=HTTPStatus(response.status_code),
|
||||
content=response.content,
|
||||
|
||||
@@ -38,9 +38,15 @@ class Client:
|
||||
_base_url: str = field(alias="base_url")
|
||||
_cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies")
|
||||
_headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers")
|
||||
_timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True, alias="timeout")
|
||||
_verify_ssl: Union[str, bool, ssl.SSLContext] = field(default=True, kw_only=True, alias="verify_ssl")
|
||||
_follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects")
|
||||
_timeout: Optional[httpx.Timeout] = field(
|
||||
default=None, kw_only=True, alias="timeout"
|
||||
)
|
||||
_verify_ssl: Union[str, bool, ssl.SSLContext] = field(
|
||||
default=True, kw_only=True, alias="verify_ssl"
|
||||
)
|
||||
_follow_redirects: bool = field(
|
||||
default=False, kw_only=True, alias="follow_redirects"
|
||||
)
|
||||
_httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args")
|
||||
_client: Optional[httpx.Client] = field(default=None, init=False)
|
||||
_async_client: Optional[httpx.AsyncClient] = field(default=None, init=False)
|
||||
@@ -168,9 +174,15 @@ class AuthenticatedClient:
|
||||
_base_url: str = field(alias="base_url")
|
||||
_cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies")
|
||||
_headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers")
|
||||
_timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True, alias="timeout")
|
||||
_verify_ssl: Union[str, bool, ssl.SSLContext] = field(default=True, kw_only=True, alias="verify_ssl")
|
||||
_follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects")
|
||||
_timeout: Optional[httpx.Timeout] = field(
|
||||
default=None, kw_only=True, alias="timeout"
|
||||
)
|
||||
_verify_ssl: Union[str, bool, ssl.SSLContext] = field(
|
||||
default=True, kw_only=True, alias="verify_ssl"
|
||||
)
|
||||
_follow_redirects: bool = field(
|
||||
default=False, kw_only=True, alias="follow_redirects"
|
||||
)
|
||||
_httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args")
|
||||
_client: Optional[httpx.Client] = field(default=None, init=False)
|
||||
_async_client: Optional[httpx.AsyncClient] = field(default=None, init=False)
|
||||
@@ -214,7 +226,9 @@ class AuthenticatedClient:
|
||||
def get_httpx_client(self) -> httpx.Client:
|
||||
"""Get the underlying httpx.Client, constructing a new one if not previously set"""
|
||||
if self._client is None:
|
||||
self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token
|
||||
self._headers[self.auth_header_name] = (
|
||||
f"{self.prefix} {self.token}" if self.prefix else self.token
|
||||
)
|
||||
self._client = httpx.Client(
|
||||
base_url=self._base_url,
|
||||
cookies=self._cookies,
|
||||
@@ -235,7 +249,9 @@ class AuthenticatedClient:
|
||||
"""Exit a context manager for internal httpx.Client (see httpx docs)"""
|
||||
self.get_httpx_client().__exit__(*args, **kwargs)
|
||||
|
||||
def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "AuthenticatedClient":
|
||||
def set_async_httpx_client(
|
||||
self, async_client: httpx.AsyncClient
|
||||
) -> "AuthenticatedClient":
|
||||
"""Manually the underlying httpx.AsyncClient
|
||||
|
||||
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
|
||||
@@ -246,7 +262,9 @@ class AuthenticatedClient:
|
||||
def get_async_httpx_client(self) -> httpx.AsyncClient:
|
||||
"""Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
|
||||
if self._async_client is None:
|
||||
self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token
|
||||
self._headers[self.auth_header_name] = (
|
||||
f"{self.prefix} {self.token}" if self.prefix else self.token
|
||||
)
|
||||
self._async_client = httpx.AsyncClient(
|
||||
base_url=self._base_url,
|
||||
cookies=self._cookies,
|
||||
|
||||
@@ -34,7 +34,9 @@ class Activity:
|
||||
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
from ..models.activity_activity_metadata_type_0 import ActivityActivityMetadataType0
|
||||
from ..models.activity_activity_metadata_type_0 import (
|
||||
ActivityActivityMetadataType0,
|
||||
)
|
||||
|
||||
user_id = self.user_id
|
||||
|
||||
@@ -69,7 +71,9 @@ class Activity:
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
|
||||
from ..models.activity_activity_metadata_type_0 import ActivityActivityMetadataType0
|
||||
from ..models.activity_activity_metadata_type_0 import (
|
||||
ActivityActivityMetadataType0,
|
||||
)
|
||||
|
||||
d = dict(src_dict)
|
||||
user_id = d.pop("user_id")
|
||||
@@ -80,7 +84,9 @@ class Activity:
|
||||
|
||||
created_at = isoparse(d.pop("created_at"))
|
||||
|
||||
def _parse_activity_metadata(data: object) -> Union["ActivityActivityMetadataType0", None, Unset]:
|
||||
def _parse_activity_metadata(
|
||||
data: object,
|
||||
) -> Union["ActivityActivityMetadataType0", None, Unset]:
|
||||
if data is None:
|
||||
return data
|
||||
if isinstance(data, Unset):
|
||||
|
||||
@@ -5,7 +5,9 @@ from attrs import define as _attrs_define
|
||||
from attrs import field as _attrs_field
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..models.coaching_session_create_conversation import CoachingSessionCreateConversation
|
||||
from ..models.coaching_session_create_conversation import (
|
||||
CoachingSessionCreateConversation,
|
||||
)
|
||||
|
||||
|
||||
T = TypeVar("T", bound="CoachingSessionCreate")
|
||||
@@ -36,10 +38,14 @@ class CoachingSessionCreate:
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
|
||||
from ..models.coaching_session_create_conversation import CoachingSessionCreateConversation
|
||||
from ..models.coaching_session_create_conversation import (
|
||||
CoachingSessionCreateConversation,
|
||||
)
|
||||
|
||||
d = dict(src_dict)
|
||||
conversation = CoachingSessionCreateConversation.from_dict(d.pop("conversation"))
|
||||
conversation = CoachingSessionCreateConversation.from_dict(
|
||||
d.pop("conversation")
|
||||
)
|
||||
|
||||
coaching_session_create = cls(
|
||||
conversation=conversation,
|
||||
|
||||
@@ -71,7 +71,9 @@ class User:
|
||||
|
||||
id = d.pop("id")
|
||||
|
||||
def _parse_preferences(data: object) -> Union["UserPreferencesType0", None, Unset]:
|
||||
def _parse_preferences(
|
||||
data: object,
|
||||
) -> Union["UserPreferencesType0", None, Unset]:
|
||||
if data is None:
|
||||
return data
|
||||
if isinstance(data, Unset):
|
||||
|
||||
@@ -64,7 +64,9 @@ class UserCreate:
|
||||
|
||||
email = d.pop("email")
|
||||
|
||||
def _parse_preferences(data: object) -> Union["UserCreatePreferencesType0", None, Unset]:
|
||||
def _parse_preferences(
|
||||
data: object,
|
||||
) -> Union["UserCreatePreferencesType0", None, Unset]:
|
||||
if data is None:
|
||||
return data
|
||||
if isinstance(data, Unset):
|
||||
|
||||
@@ -86,7 +86,9 @@ class UserUpdate:
|
||||
|
||||
email = _parse_email(d.pop("email", UNSET))
|
||||
|
||||
def _parse_preferences(data: object) -> Union["UserUpdatePreferencesType0", None, Unset]:
|
||||
def _parse_preferences(
|
||||
data: object,
|
||||
) -> Union["UserUpdatePreferencesType0", None, Unset]:
|
||||
if data is None:
|
||||
return data
|
||||
if isinstance(data, Unset):
|
||||
|
||||
@@ -41,7 +41,9 @@ class WorkoutPlanCreate:
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
|
||||
from ..models.workout_plan_create_plan_details import WorkoutPlanCreatePlanDetails
|
||||
from ..models.workout_plan_create_plan_details import (
|
||||
WorkoutPlanCreatePlanDetails,
|
||||
)
|
||||
|
||||
d = dict(src_dict)
|
||||
user_id = d.pop("user_id")
|
||||
|
||||
@@ -7,7 +7,9 @@ from attrs import field as _attrs_field
|
||||
from ..types import UNSET, Unset
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..models.workout_plan_update_plan_details_type_0 import WorkoutPlanUpdatePlanDetailsType0
|
||||
from ..models.workout_plan_update_plan_details_type_0 import (
|
||||
WorkoutPlanUpdatePlanDetailsType0,
|
||||
)
|
||||
|
||||
|
||||
T = TypeVar("T", bound="WorkoutPlanUpdate")
|
||||
@@ -24,7 +26,9 @@ class WorkoutPlanUpdate:
|
||||
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
from ..models.workout_plan_update_plan_details_type_0 import WorkoutPlanUpdatePlanDetailsType0
|
||||
from ..models.workout_plan_update_plan_details_type_0 import (
|
||||
WorkoutPlanUpdatePlanDetailsType0,
|
||||
)
|
||||
|
||||
plan_details: Union[None, Unset, dict[str, Any]]
|
||||
if isinstance(self.plan_details, Unset):
|
||||
@@ -44,11 +48,15 @@ class WorkoutPlanUpdate:
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
|
||||
from ..models.workout_plan_update_plan_details_type_0 import WorkoutPlanUpdatePlanDetailsType0
|
||||
from ..models.workout_plan_update_plan_details_type_0 import (
|
||||
WorkoutPlanUpdatePlanDetailsType0,
|
||||
)
|
||||
|
||||
d = dict(src_dict)
|
||||
|
||||
def _parse_plan_details(data: object) -> Union["WorkoutPlanUpdatePlanDetailsType0", None, Unset]:
|
||||
def _parse_plan_details(
|
||||
data: object,
|
||||
) -> Union["WorkoutPlanUpdatePlanDetailsType0", None, Unset]:
|
||||
if data is None:
|
||||
return data
|
||||
if isinstance(data, Unset):
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
APP_NAME: str = "GarminSync Backend"
|
||||
DEBUG: bool = False
|
||||
@@ -18,12 +19,14 @@ class Settings(BaseSettings):
|
||||
GARMIN_CONNECT_EMAIL: str = ""
|
||||
GARMIN_CONNECT_PASSWORD: str = ""
|
||||
CENTRAL_DB_URL: str
|
||||
DATABASE_URL: Optional[str] = None # Added to handle potential old .env variable
|
||||
GARMINSYNC_DATA_DIR: Path = Path("data")
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
# Create data directory if it doesn't exist
|
||||
@@ -31,6 +34,7 @@ settings = Settings()
|
||||
|
||||
_deprecation_warned = False
|
||||
|
||||
|
||||
def get_garmin_credentials() -> Tuple[str, str]:
|
||||
"""Get Garmin Connect credentials from environment variables.
|
||||
|
||||
|
||||
@@ -10,62 +10,66 @@ from .services.garmin_auth_service import GarminAuthService # New import
|
||||
from .services.garmin_client_service import GarminClientService, garmin_client_service
|
||||
from .services.garmin_health_service import GarminHealthService
|
||||
from .services.garmin_workout_service import GarminWorkoutService
|
||||
from .services.sync_status_service import SyncStatusService
|
||||
|
||||
|
||||
def get_central_db_service() -> CentralDBService:
|
||||
return CentralDBService(base_url=settings.CENTRAL_DB_URL)
|
||||
|
||||
|
||||
def get_auth_service() -> AuthService:
|
||||
return AuthService()
|
||||
|
||||
def get_garmin_auth_service() -> GarminAuthService: # New dependency function
|
||||
|
||||
def get_garmin_auth_service() -> GarminAuthService: # New dependency function
|
||||
return GarminAuthService()
|
||||
|
||||
|
||||
def get_garmin_client_service() -> GarminClientService:
|
||||
return garmin_client_service
|
||||
|
||||
|
||||
def get_activity_download_service(
|
||||
garmin_client_service: GarminClientService = Depends(get_garmin_client_service)
|
||||
garmin_client_service: GarminClientService = Depends(get_garmin_client_service),
|
||||
) -> ActivityDownloadService:
|
||||
return ActivityDownloadService(garmin_client_instance=garmin_client_service)
|
||||
|
||||
|
||||
def get_garmin_activity_service(
|
||||
garmin_client_service: GarminClientService = Depends(get_garmin_client_service),
|
||||
activity_download_service: ActivityDownloadService = Depends(get_activity_download_service),
|
||||
activity_download_service: ActivityDownloadService = Depends(
|
||||
get_activity_download_service
|
||||
),
|
||||
garmin_auth_service: GarminAuthService = Depends(get_garmin_auth_service),
|
||||
central_db_service: CentralDBService = Depends(get_central_db_service)
|
||||
central_db_service: CentralDBService = Depends(get_central_db_service),
|
||||
) -> GarminActivityService:
|
||||
return GarminActivityService(
|
||||
garmin_client_service=garmin_client_service,
|
||||
activity_download_service=activity_download_service,
|
||||
garmin_auth_service=garmin_auth_service,
|
||||
central_db_service=central_db_service
|
||||
central_db_service=central_db_service,
|
||||
)
|
||||
|
||||
|
||||
def get_garmin_health_service(
|
||||
garmin_client_service: GarminClientService = Depends(get_garmin_client_service),
|
||||
central_db_service: CentralDBService = Depends(get_central_db_service),
|
||||
garmin_auth_service: GarminAuthService = Depends(get_garmin_auth_service)
|
||||
garmin_auth_service: GarminAuthService = Depends(get_garmin_auth_service),
|
||||
) -> GarminHealthService:
|
||||
return GarminHealthService(
|
||||
garmin_client_service=garmin_client_service,
|
||||
central_db_service=central_db_service,
|
||||
garmin_auth_service=garmin_auth_service
|
||||
garmin_auth_service=garmin_auth_service,
|
||||
)
|
||||
|
||||
|
||||
def get_garmin_workout_service() -> GarminWorkoutService:
|
||||
return GarminWorkoutService(garmin_client_service) # Assuming it needs garmin_client_service
|
||||
return GarminWorkoutService(
|
||||
garmin_client_service
|
||||
) # Assuming it needs garmin_client_service
|
||||
|
||||
from .jobs import job_store
|
||||
|
||||
# ... other imports ...
|
||||
|
||||
def get_sync_status_service() -> SyncStatusService:
|
||||
return SyncStatusService(job_store=job_store)
|
||||
|
||||
async def get_current_user(
|
||||
central_db_service: CentralDBService = Depends(get_central_db_service)
|
||||
central_db_service: CentralDBService = Depends(get_central_db_service),
|
||||
) -> User:
|
||||
# As per spec, this is a single-user system, so we can assume user_id = 1
|
||||
user_id = 1
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from threading import Lock
|
||||
from typing import Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SyncJob(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
status: str = "pending"
|
||||
start_time: datetime = Field(default_factory=datetime.utcnow)
|
||||
end_time: Optional[datetime] = None
|
||||
progress: float = 0.0
|
||||
error_message: Optional[str] = None
|
||||
details: Dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class JobStore:
|
||||
def __init__(self):
|
||||
self._jobs: Dict[str, SyncJob] = {}
|
||||
self._lock = Lock()
|
||||
|
||||
def create_job(self) -> SyncJob:
|
||||
job = SyncJob()
|
||||
with self._lock:
|
||||
self._jobs[job.id] = job
|
||||
return job
|
||||
|
||||
def get_job(self, job_id: str) -> Optional[SyncJob]:
|
||||
with self._lock:
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
def get_all_jobs(self) -> list[SyncJob]:
|
||||
with self._lock:
|
||||
return list(self._jobs.values())
|
||||
|
||||
def update_job(
|
||||
self,
|
||||
job_id: str,
|
||||
status: str,
|
||||
progress: float,
|
||||
details: Optional[Dict] = None,
|
||||
error_message: Optional[str] = None
|
||||
):
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if job:
|
||||
job.status = status
|
||||
job.progress = progress
|
||||
if details:
|
||||
job.details = details
|
||||
if error_message:
|
||||
job.error_message = error_message
|
||||
if status in ["completed", "failed"]:
|
||||
job.end_time = datetime.utcnow()
|
||||
|
||||
job_store = JobStore()
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
|
||||
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
|
||||
|
||||
def setup_logging():
|
||||
logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
|
||||
# You can add more sophisticated logging handlers here, e.g., file handlers, Sentry, etc.
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ app = FastAPI(title=settings.APP_NAME)
|
||||
rate_limiter = RateLimiter(rate_limit="100/minute")
|
||||
|
||||
# Initialize CentralDBService
|
||||
central_db_service = CentralDBService(base_url=settings.CENTRAL_DB_URL) # Assuming CENTRAL_DB_URL in settings
|
||||
central_db_service = CentralDBService(
|
||||
base_url=settings.CENTRAL_DB_URL
|
||||
) # Assuming CENTRAL_DB_URL in settings
|
||||
|
||||
app.include_router(garmin_sync.router, prefix="/api/sync", tags=["Garmin Sync"])
|
||||
app.include_router(garmin_auth.router, prefix="/api/garmin", tags=["Garmin Auth"])
|
||||
@@ -52,6 +54,7 @@ app.include_router(garmin_auth.router, prefix="/api/garmin", tags=["Garmin Auth"
|
||||
# )
|
||||
# return {"message": "Login successful"}
|
||||
|
||||
|
||||
@app.post("/logout")
|
||||
async def logout(response: Response):
|
||||
response.delete_cookie(key=settings.SESSION_COOKIE_NAME)
|
||||
@@ -65,14 +68,18 @@ async def general_exception_handler(request: Request, exc: Exception):
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"message": "An unexpected error occurred.",
|
||||
"detail": str(exc) if settings.DEBUG else None, # Only show detail in debug mode
|
||||
"detail": (
|
||||
str(exc) if settings.DEBUG else None
|
||||
), # Only show detail in debug mode
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Welcome to GarminSync Backend!"}
|
||||
|
||||
|
||||
@app.post("/background-test", dependencies=[Depends(rate_limiter)])
|
||||
async def run_background_test(background_tasks: BackgroundTasks):
|
||||
message = "This is a test background task."
|
||||
|
||||
0
backend/src/models/__init__.py
Normal file
0
backend/src/models/__init__.py
Normal file
18
backend/src/models/sync_job.py
Normal file
18
backend/src/models/sync_job.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
SyncJobStatus = Literal["pending", "in_progress", "completed", "failed"]
|
||||
SyncJobType = Literal["activities", "health", "workouts"]
|
||||
|
||||
|
||||
class SyncJob(BaseModel):
|
||||
status: SyncJobStatus = "pending"
|
||||
progress: float = 0.0
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
error_message: Optional[str] = None
|
||||
job_type: Optional[SyncJobType] = None
|
||||
@@ -8,9 +8,11 @@ class UserBase(BaseModel):
|
||||
name: str
|
||||
email: str
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
pass
|
||||
|
||||
|
||||
class User(UserBase):
|
||||
id: int
|
||||
preferences: Optional[Dict[str, Any]] = None
|
||||
@@ -18,17 +20,21 @@ class User(UserBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TokenBase(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_at: int # Unix timestamp
|
||||
expires_at: int # Unix timestamp
|
||||
|
||||
|
||||
class TokenCreate(TokenBase):
|
||||
user_id: int
|
||||
|
||||
|
||||
class TokenUpdate(TokenBase):
|
||||
user_id: int
|
||||
|
||||
|
||||
class Token(TokenBase):
|
||||
id: int
|
||||
user_id: int
|
||||
@@ -38,15 +44,18 @@ class Token(TokenBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class WorkoutPlan(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
plan_details: Dict[str, Any]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ActivitySyncRequest(BaseModel):
|
||||
force_resync: bool = Field(
|
||||
False, description="If true, re-download activities even if they exist. Defaults to false."
|
||||
False,
|
||||
description="If true, re-download activities even if they exist. Defaults to false.",
|
||||
)
|
||||
start_date: Optional[date] = Field(
|
||||
None,
|
||||
@@ -59,16 +68,22 @@ class ActivitySyncRequest(BaseModel):
|
||||
None, description="Optional end date (YYYY-MM-DD) to sync activities up to."
|
||||
)
|
||||
|
||||
|
||||
class WorkoutUploadRequest(BaseModel):
|
||||
workout_id: int = Field(..., description="The ID of the workout to upload from CentralDB.")
|
||||
workout_id: int = Field(
|
||||
..., description="The ID of the workout to upload from CentralDB."
|
||||
)
|
||||
|
||||
|
||||
class GarminCredentials(BaseModel):
|
||||
garmin_username: str
|
||||
garmin_password_plaintext: str # NOTE: Storing in plaintext as per user requirement. This is a security risk.
|
||||
garmin_password_plaintext: str # NOTE: Storing in plaintext as per user requirement. This is a security risk.
|
||||
|
||||
|
||||
class GarminLoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class GarminLoginResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
@@ -8,11 +8,14 @@ from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ActivityDownloadService:
|
||||
def __init__(self, garmin_client_instance):
|
||||
self.garmin_client = garmin_client_instance
|
||||
|
||||
def download_activity_original(self, activity_id: str, force_download: bool = False) -> Optional[Path]:
|
||||
def download_activity_original(
|
||||
self, activity_id: str, force_download: bool = False
|
||||
) -> Optional[Path]:
|
||||
"""Download original activity file (usually FIT format).
|
||||
|
||||
Args:
|
||||
@@ -36,7 +39,7 @@ class ActivityDownloadService:
|
||||
attempts: List[str] = []
|
||||
|
||||
# 1) Prefer native method when available
|
||||
if hasattr(self.garmin_client.client, 'download_activity_original'):
|
||||
if hasattr(self.garmin_client.client, "download_activity_original"):
|
||||
try:
|
||||
attempts.append(
|
||||
"self.garmin_client.client.download_activity_original(activity_id)"
|
||||
@@ -44,7 +47,9 @@ class ActivityDownloadService:
|
||||
logger.debug(
|
||||
f"Attempting native download_activity_original for activity {activity_id}"
|
||||
)
|
||||
file_data = self.garmin_client.client.download_activity_original(activity_id)
|
||||
file_data = self.garmin_client.client.download_activity_original(
|
||||
activity_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Native download_activity_original failed: {e} (type={type(e).__name__})"
|
||||
@@ -52,7 +57,9 @@ class ActivityDownloadService:
|
||||
file_data = None
|
||||
|
||||
# 2) Try download_activity with 'original' format
|
||||
if file_data is None and hasattr(self.garmin_client.client, 'download_activity'):
|
||||
if file_data is None and hasattr(
|
||||
self.garmin_client.client, "download_activity"
|
||||
):
|
||||
try:
|
||||
attempts.append(
|
||||
"self.garmin_client.client.download_activity(activity_id, "
|
||||
@@ -64,14 +71,19 @@ class ActivityDownloadService:
|
||||
f"for activity {activity_id}"
|
||||
)
|
||||
file_data = self.garmin_client.client.download_activity(
|
||||
activity_id, dl_fmt=self.garmin_client.client.ActivityDownloadFormat.ORIGINAL
|
||||
activity_id,
|
||||
dl_fmt=self.garmin_client.client.ActivityDownloadFormat.ORIGINAL,
|
||||
)
|
||||
logger.debug(
|
||||
f"download_activity(dl_fmt='original') succeeded, got data type: "
|
||||
f"{type(file_data).__name__}, length: "
|
||||
f"{len(file_data) if hasattr(file_data, '__len__') else 'N/A'}"
|
||||
)
|
||||
if file_data is not None and hasattr(file_data, '__len__') and len(file_data) > 0:
|
||||
if (
|
||||
file_data is not None
|
||||
and hasattr(file_data, "__len__")
|
||||
and len(file_data) > 0
|
||||
):
|
||||
logger.debug(f"First 100 bytes: {file_data[:100]}")
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
@@ -80,8 +92,10 @@ class ActivityDownloadService:
|
||||
file_data = None
|
||||
|
||||
# 3) Try download_activity with positional token (older signatures)
|
||||
if file_data is None and hasattr(self.garmin_client.client, 'download_activity'):
|
||||
tokens_to_try_pos = ['ORIGINAL', 'original', 'FIT', 'fit']
|
||||
if file_data is None and hasattr(
|
||||
self.garmin_client.client, "download_activity"
|
||||
):
|
||||
tokens_to_try_pos = ["ORIGINAL", "original", "FIT", "fit"]
|
||||
for token in tokens_to_try_pos:
|
||||
try:
|
||||
attempts.append(
|
||||
@@ -91,13 +105,19 @@ class ActivityDownloadService:
|
||||
"Attempting original download via download_activity("
|
||||
f"activity_id, '{token}') for activity {activity_id}"
|
||||
)
|
||||
file_data = self.garmin_client.client.download_activity(activity_id, token)
|
||||
file_data = self.garmin_client.client.download_activity(
|
||||
activity_id, token
|
||||
)
|
||||
logger.debug(
|
||||
f"download_activity(activity_id, '{token}') succeeded, got data type: "
|
||||
f"{type(file_data).__name__}, length: "
|
||||
f"{len(file_data) if hasattr(file_data, '__len__') else 'N/A'}"
|
||||
)
|
||||
if file_data is not None and hasattr(file_data, '__len__') and len(file_data) > 0:
|
||||
if (
|
||||
file_data is not None
|
||||
and hasattr(file_data, "__len__")
|
||||
and len(file_data) > 0
|
||||
):
|
||||
logger.debug(f"First 100 bytes: {file_data[:100]}")
|
||||
break
|
||||
except Exception as e:
|
||||
@@ -113,12 +133,12 @@ class ActivityDownloadService:
|
||||
)
|
||||
return None
|
||||
|
||||
if hasattr(file_data, 'content'):
|
||||
if hasattr(file_data, "content"):
|
||||
try:
|
||||
file_data = file_data.content
|
||||
except Exception:
|
||||
pass
|
||||
elif hasattr(file_data, 'read'):
|
||||
elif hasattr(file_data, "read"):
|
||||
try:
|
||||
file_data = file_data.read()
|
||||
except Exception:
|
||||
@@ -136,21 +156,29 @@ class ActivityDownloadService:
|
||||
tmp_file.write(file_data)
|
||||
tmp_path = Path(tmp_file.name)
|
||||
|
||||
extracted_path = settings.GARMINSYNC_DATA_DIR / f"activity_{activity_id}.fit"
|
||||
extracted_path = (
|
||||
settings.GARMINSYNC_DATA_DIR / f"activity_{activity_id}.fit"
|
||||
)
|
||||
|
||||
if zipfile.is_zipfile(tmp_path):
|
||||
with zipfile.ZipFile(tmp_path, 'r') as zip_ref:
|
||||
fit_files = [f for f in zip_ref.namelist() if f.lower().endswith('.fit')]
|
||||
with zipfile.ZipFile(tmp_path, "r") as zip_ref:
|
||||
fit_files = [
|
||||
f for f in zip_ref.namelist() if f.lower().endswith(".fit")
|
||||
]
|
||||
|
||||
if fit_files:
|
||||
fit_filename = fit_files[0]
|
||||
|
||||
with zip_ref.open(fit_filename) as source, open(extracted_path, 'wb') as target:
|
||||
with zip_ref.open(fit_filename) as source, open(
|
||||
extracted_path, "wb"
|
||||
) as target:
|
||||
target.write(source.read())
|
||||
|
||||
tmp_path.unlink()
|
||||
|
||||
logger.info(f"Downloaded original activity file: {extracted_path}")
|
||||
logger.info(
|
||||
f"Downloaded original activity file: {extracted_path}"
|
||||
)
|
||||
downloaded_path = extracted_path
|
||||
else:
|
||||
logger.warning("No FIT file found in downloaded archive")
|
||||
@@ -164,7 +192,9 @@ class ActivityDownloadService:
|
||||
f"Rename temp FIT to destination failed ({move_err}); "
|
||||
"falling back to copy"
|
||||
)
|
||||
with open(extracted_path, 'wb') as target, open(tmp_path, 'rb') as source:
|
||||
with open(extracted_path, "wb") as target, open(
|
||||
tmp_path, "rb"
|
||||
) as source:
|
||||
target.write(source.read())
|
||||
tmp_path.unlink()
|
||||
downloaded_path = extracted_path
|
||||
|
||||
@@ -11,6 +11,7 @@ from .central_db_service import CentralDBService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(self):
|
||||
self.central_db = CentralDBService(base_url=settings.CENTRAL_DB_URL)
|
||||
@@ -24,13 +25,17 @@ class AuthService:
|
||||
if not session_cookie:
|
||||
return None
|
||||
try:
|
||||
user_id = self.serializer.loads(session_cookie, max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60)
|
||||
user_id = self.serializer.loads(
|
||||
session_cookie, max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
)
|
||||
user = await self.central_db.get_user(user_id=user_id)
|
||||
return user
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def authenticate_garmin_connect(self, email: str, password: str) -> Optional[User]:
|
||||
async def authenticate_garmin_connect(
|
||||
self, email: str, password: str
|
||||
) -> Optional[User]:
|
||||
"""
|
||||
Authenticates with Garmin Connect, and returns the user object.
|
||||
"""
|
||||
|
||||
@@ -3,6 +3,7 @@ import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def example_background_task(message: str):
|
||||
logger.info(f"Starting background task with message: {message}")
|
||||
time.sleep(5) # Simulate a long-running task
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
||||
from tenacity import (
|
||||
retry,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from ..schemas import GarminCredentials, Token, User, WorkoutPlan
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -13,10 +18,11 @@ logger = logging.getLogger(__name__)
|
||||
CENTRAL_DB_RETRY_STRATEGY = retry(
|
||||
stop=stop_after_attempt(5),
|
||||
wait=wait_exponential(multiplier=1, min=2, max=10),
|
||||
retry=retry_if_exception_type(httpx.RequestError), # Retry on network errors
|
||||
reraise=True
|
||||
retry=retry_if_exception_type(httpx.RequestError), # Retry on network errors
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
|
||||
class CentralDBService:
|
||||
def __init__(self, base_url: str):
|
||||
self.base_url = base_url
|
||||
@@ -73,7 +79,9 @@ class CentralDBService:
|
||||
async def create_token(self, token_create: dict) -> Optional[Token]:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(f"{self.base_url}/tokens/", json=token_create)
|
||||
response = await client.post(
|
||||
f"{self.base_url}/tokens/", json=token_create
|
||||
)
|
||||
response.raise_for_status()
|
||||
return Token(**response.json())
|
||||
except Exception as e:
|
||||
@@ -84,7 +92,9 @@ class CentralDBService:
|
||||
async def update_token(self, user_id: int, token_update: dict) -> Optional[Token]:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.put(f"{self.base_url}/tokens/{user_id}", json=token_update)
|
||||
response = await client.put(
|
||||
f"{self.base_url}/tokens/{user_id}", json=token_update
|
||||
)
|
||||
response.raise_for_status()
|
||||
return Token(**response.json())
|
||||
except Exception as e:
|
||||
@@ -95,7 +105,9 @@ class CentralDBService:
|
||||
async def get_workout_by_id(self, workout_id: int) -> Optional[WorkoutPlan]:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(f"{self.base_url}/workout_plans/{workout_id}")
|
||||
response = await client.get(
|
||||
f"{self.base_url}/workout_plans/{workout_id}"
|
||||
)
|
||||
response.raise_for_status()
|
||||
return WorkoutPlan(**response.json())
|
||||
except Exception as e:
|
||||
@@ -108,24 +120,33 @@ class CentralDBService:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": (file_path.name, f, "application/fit")} # Changed content type
|
||||
user_id = 1 # Assuming single user for now
|
||||
files = {
|
||||
"file": (file_path.name, f, "application/fit")
|
||||
} # Changed content type
|
||||
user_id = 1 # Assuming single user for now
|
||||
response = await client.post(
|
||||
f"{self.base_url}/activities/{user_id}", # user_id as path parameter
|
||||
f"{self.base_url}/activities/{user_id}", # user_id as path parameter
|
||||
files=files,
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info(f"Successfully uploaded activity {activity_id} to CentralDB.")
|
||||
logger.info(
|
||||
f"Successfully uploaded activity {activity_id} to CentralDB."
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error uploading activity {activity_id} to CentralDB: {e}", exc_info=True)
|
||||
logger.error(
|
||||
f"Error uploading activity {activity_id} to CentralDB: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
@CENTRAL_DB_RETRY_STRATEGY
|
||||
async def save_health_metric(self, health_metric_data: dict) -> Optional[dict]:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(f"{self.base_url}/health_metrics", json=health_metric_data)
|
||||
response = await client.post(
|
||||
f"{self.base_url}/health_metrics", json=health_metric_data
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
@@ -136,7 +157,9 @@ class CentralDBService:
|
||||
async def get_garmin_credentials(self, user_id: int) -> Optional[GarminCredentials]:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(f"{self.base_url}/garmin_credentials/{user_id}")
|
||||
response = await client.get(
|
||||
f"{self.base_url}/garmin_credentials/{user_id}"
|
||||
)
|
||||
response.raise_for_status()
|
||||
return GarminCredentials(**response.json())
|
||||
except Exception as e:
|
||||
@@ -144,10 +167,15 @@ class CentralDBService:
|
||||
return None
|
||||
|
||||
@CENTRAL_DB_RETRY_STRATEGY
|
||||
async def create_garmin_credentials(self, user_id: int, credentials_data: dict) -> Optional[GarminCredentials]:
|
||||
async def create_garmin_credentials(
|
||||
self, user_id: int, credentials_data: dict
|
||||
) -> Optional[GarminCredentials]:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(f"{self.base_url}/garmin_credentials/{user_id}", json=credentials_data)
|
||||
response = await client.post(
|
||||
f"{self.base_url}/garmin_credentials/{user_id}",
|
||||
json=credentials_data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return GarminCredentials(**response.json())
|
||||
except Exception as e:
|
||||
@@ -155,10 +183,15 @@ class CentralDBService:
|
||||
return None
|
||||
|
||||
@CENTRAL_DB_RETRY_STRATEGY
|
||||
async def update_garmin_credentials(self, user_id: int, credentials_data: dict) -> Optional[GarminCredentials]:
|
||||
async def update_garmin_credentials(
|
||||
self, user_id: int, credentials_data: dict
|
||||
) -> Optional[GarminCredentials]:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.put(f"{self.base_url}/garmin_credentials/{user_id}", json=credentials_data)
|
||||
response = await client.put(
|
||||
f"{self.base_url}/garmin_credentials/{user_id}",
|
||||
json=credentials_data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return GarminCredentials(**response.json())
|
||||
except Exception as e:
|
||||
|
||||
@@ -3,9 +3,13 @@ import logging
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
||||
from tenacity import (
|
||||
retry,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from ..jobs import job_store
|
||||
from ..services.activity_download_service import ActivityDownloadService
|
||||
from ..services.central_db_service import CentralDBService
|
||||
from ..services.garmin_auth_service import GarminAuthService
|
||||
@@ -17,15 +21,17 @@ logger = logging.getLogger(__name__)
|
||||
GARMIN_RETRY_STRATEGY = retry(
|
||||
stop=stop_after_attempt(5),
|
||||
wait=wait_exponential(multiplier=1, min=4, max=10),
|
||||
retry=retry_if_exception_type(Exception), # Broad exception for now, refine later
|
||||
reraise=True
|
||||
retry=retry_if_exception_type(Exception), # Broad exception for now, refine later
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
|
||||
# Placeholder for SHA256 calculation - to be implemented in a utility module
|
||||
def calculate_sha256(file_path) -> str:
|
||||
# This is a placeholder. Actual implementation would read the file and compute SHA256.
|
||||
return "mock_sha256_checksum"
|
||||
|
||||
|
||||
class GarminActivityService:
|
||||
|
||||
def __init__(
|
||||
@@ -33,14 +39,16 @@ class GarminActivityService:
|
||||
garmin_client_service: GarminClientService,
|
||||
activity_download_service: ActivityDownloadService,
|
||||
garmin_auth_service: GarminAuthService,
|
||||
central_db_service: CentralDBService
|
||||
central_db_service: CentralDBService,
|
||||
):
|
||||
self.garmin_client_service = garmin_client_service
|
||||
self.activity_download_service = activity_download_service
|
||||
self.garmin_auth_service = garmin_auth_service
|
||||
self.central_db_service = central_db_service
|
||||
|
||||
async def _get_authenticated_garmin_client(self, user_id: int) -> Optional[GarminClientService]:
|
||||
async def _get_authenticated_garmin_client(
|
||||
self, user_id: int
|
||||
) -> Optional[GarminClientService]:
|
||||
credentials = await self.central_db_service.get_garmin_credentials(user_id)
|
||||
if not credentials:
|
||||
logger.error(f"No Garmin credentials found for user {user_id}.")
|
||||
@@ -55,17 +63,24 @@ class GarminActivityService:
|
||||
# Check if the client is authenticated after updating credentials
|
||||
if not self.garmin_client_service.is_authenticated():
|
||||
if not self.garmin_client_service.authenticate():
|
||||
logger.error(f"Failed to authenticate Garmin client for user {user_id}.")
|
||||
logger.error(
|
||||
f"Failed to authenticate Garmin client for user {user_id}."
|
||||
)
|
||||
return None
|
||||
|
||||
return self.garmin_client_service
|
||||
|
||||
@GARMIN_RETRY_STRATEGY
|
||||
async def download_and_save_activity(
|
||||
self, user_id: int, activity_id: str, force_download: bool = False,
|
||||
garmin_client: Optional[GarminClientService] = None # New argument
|
||||
self,
|
||||
user_id: int,
|
||||
activity_id: str,
|
||||
force_download: bool = False,
|
||||
garmin_client: Optional[GarminClientService] = None, # New argument
|
||||
) -> Optional[dict]:
|
||||
_garmin_client = garmin_client or await self._get_authenticated_garmin_client(user_id)
|
||||
_garmin_client = garmin_client or await self._get_authenticated_garmin_client(
|
||||
user_id
|
||||
)
|
||||
if not _garmin_client:
|
||||
return None
|
||||
|
||||
@@ -75,37 +90,49 @@ class GarminActivityService:
|
||||
# CentralDB will be responsible for handling duplicates.
|
||||
|
||||
# Download the original activity file (FIT)
|
||||
downloaded_file_path = self.activity_download_service.download_activity_original(
|
||||
activity_id=activity_id,
|
||||
force_download=force_download
|
||||
downloaded_file_path = (
|
||||
self.activity_download_service.download_activity_original(
|
||||
activity_id=activity_id, force_download=force_download
|
||||
)
|
||||
)
|
||||
|
||||
if not downloaded_file_path:
|
||||
logger.error(f"Failed to download activity file for activity ID: {activity_id}")
|
||||
logger.error(
|
||||
f"Failed to download activity file for activity ID: {activity_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Upload the file to CentralDB
|
||||
# from .central_db_service import CentralDBService # No longer needed, injected
|
||||
# central_db = CentralDBService(base_url=settings.CENTRAL_DB_URL)
|
||||
success = await self.central_db_service.upload_activity_file(activity_id, downloaded_file_path)
|
||||
success = await self.central_db_service.upload_activity_file(
|
||||
activity_id, downloaded_file_path
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.error(f"Failed to upload activity file to CentralDB for activity ID: {activity_id}")
|
||||
logger.error(
|
||||
f"Failed to upload activity file to CentralDB for activity ID: {activity_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Get activity details from Garmin Connect (to extract metadata)
|
||||
garmin_activity_details = _garmin_client.get_client().get_activity_details(activity_id)
|
||||
garmin_activity_details = _garmin_client.get_client().get_activity_details(
|
||||
activity_id
|
||||
)
|
||||
|
||||
return garmin_activity_details
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error downloading and saving activity {activity_id}: {e}", exc_info=True
|
||||
f"Error downloading and saving activity {activity_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
@GARMIN_RETRY_STRATEGY
|
||||
async def get_activities_for_sync(self, user_id: int, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
async def get_activities_for_sync(
|
||||
self, user_id: int, limit: int = 20
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get a list of recent activities from Garmin Connect."""
|
||||
garmin_client = await self._get_authenticated_garmin_client(user_id)
|
||||
if not garmin_client:
|
||||
@@ -122,20 +149,22 @@ class GarminActivityService:
|
||||
|
||||
async def sync_activities_in_background(
|
||||
self,
|
||||
job_id: str,
|
||||
user_id: int,
|
||||
current_sync_job_manager,
|
||||
force_resync: bool = False,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
max_activities_to_sync: Optional[int] = 10, # Default to 10 activities
|
||||
max_activities_to_sync: Optional[int] = 10, # Default to 10 activities
|
||||
):
|
||||
user_id = 1 # Assuming single user for now
|
||||
try:
|
||||
job_store.update_job(job_id, status="in_progress", progress=0.0)
|
||||
# user_id = 1 # Assuming single user for now - now passed as argument
|
||||
|
||||
# Authenticate Garmin client once at the beginning
|
||||
garmin_client = await self._get_authenticated_garmin_client(user_id)
|
||||
if not garmin_client:
|
||||
raise Exception("Garmin client not authenticated or failed to get valid credentials.")
|
||||
raise Exception(
|
||||
"Garmin client not authenticated or failed to get valid credentials."
|
||||
)
|
||||
|
||||
all_garmin_activities = []
|
||||
start = 0
|
||||
@@ -148,7 +177,7 @@ class GarminActivityService:
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to fetch activities from Garmin Connect after retries: {e}",
|
||||
exc_info=True
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -158,21 +187,37 @@ class GarminActivityService:
|
||||
start += limit
|
||||
|
||||
# Break if we have collected enough activities
|
||||
if max_activities_to_sync is not None and len(all_garmin_activities) >= max_activities_to_sync:
|
||||
all_garmin_activities = all_garmin_activities[:max_activities_to_sync] # Truncate to the limit
|
||||
if (
|
||||
max_activities_to_sync is not None
|
||||
and len(all_garmin_activities) >= max_activities_to_sync
|
||||
):
|
||||
all_garmin_activities = all_garmin_activities[
|
||||
:max_activities_to_sync
|
||||
] # Truncate to the limit
|
||||
break
|
||||
|
||||
activities_to_process = []
|
||||
for activity_data in all_garmin_activities:
|
||||
activity_start_time_str = activity_data.get("startTimeGMT")
|
||||
activity_start_time = (
|
||||
datetime.fromisoformat(activity_start_time_str.replace("Z", "+00:00"))
|
||||
if activity_start_time_str else None
|
||||
datetime.fromisoformat(
|
||||
activity_start_time_str.replace("Z", "+00:00")
|
||||
)
|
||||
if activity_start_time_str
|
||||
else None
|
||||
)
|
||||
|
||||
if start_date and activity_start_time and activity_start_time.date() < start_date:
|
||||
if (
|
||||
start_date
|
||||
and activity_start_time
|
||||
and activity_start_time.date() < start_date
|
||||
):
|
||||
continue
|
||||
if end_date and activity_start_time and activity_start_time.date() > end_date:
|
||||
if (
|
||||
end_date
|
||||
and activity_start_time
|
||||
and activity_start_time.date() > end_date
|
||||
):
|
||||
continue
|
||||
activities_to_process.append(activity_data)
|
||||
|
||||
@@ -187,7 +232,7 @@ class GarminActivityService:
|
||||
user_id=user_id,
|
||||
activity_id=str(activity_data.get("activityId")),
|
||||
force_download=force_resync,
|
||||
garmin_client=garmin_client # Pass the authenticated client
|
||||
garmin_client=garmin_client, # Pass the authenticated client
|
||||
)
|
||||
)
|
||||
|
||||
@@ -199,20 +244,12 @@ class GarminActivityService:
|
||||
logger.error(f"Failed to process activity {activity_id}: {result}")
|
||||
else:
|
||||
synced_count += 1
|
||||
job_store.update_job(job_id, status="in_progress", progress=(i + 1) / total_activities)
|
||||
await current_sync_job_manager.update_progress(
|
||||
progress=(i + 1) / total_activities
|
||||
)
|
||||
|
||||
job_store.update_job(
|
||||
job_id,
|
||||
status="completed",
|
||||
progress=1.0,
|
||||
details={
|
||||
"synced_activities_count": synced_count,
|
||||
"total_activities_found": total_activities
|
||||
}
|
||||
)
|
||||
await current_sync_job_manager.complete_sync()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error during activity synchronization for job {job_id}: {e}", exc_info=True
|
||||
)
|
||||
job_store.update_job(job_id, status="failed", progress=1.0, error_message=str(e))
|
||||
logger.error(f"Error during activity synchronization: {e}", exc_info=True)
|
||||
await current_sync_job_manager.fail_sync(error_message=str(e))
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import logging
|
||||
import asyncio # Import asyncio for sleep
|
||||
from typing import Optional
|
||||
|
||||
from garminconnect import Garmin
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
||||
from tenacity import (
|
||||
retry,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from ..schemas import GarminCredentials
|
||||
|
||||
@@ -13,34 +17,49 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Define a retry strategy for Garmin login
|
||||
GARMIN_LOGIN_RETRY_STRATEGY = retry(
|
||||
stop=stop_after_attempt(10), # Increased attempts
|
||||
wait=wait_exponential(multiplier=1, min=10, max=60), # Increased min and max wait times
|
||||
retry=retry_if_exception_type(Exception), # Retry on any exception for now
|
||||
reraise=True
|
||||
stop=stop_after_attempt(10), # Increased attempts
|
||||
wait=wait_exponential(
|
||||
multiplier=1, min=10, max=60
|
||||
), # Increased min and max wait times
|
||||
retry=retry_if_exception_type(Exception), # Retry on any exception for now
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
|
||||
class GarminAuthService:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@GARMIN_LOGIN_RETRY_STRATEGY # Apply retry strategy here
|
||||
@GARMIN_LOGIN_RETRY_STRATEGY # Apply retry strategy here
|
||||
async def _perform_login(self, username: str, password: str) -> Garmin:
|
||||
"""Helper to perform the actual garminconnect login with retry."""
|
||||
client = Garmin(username, password)
|
||||
client.login()
|
||||
return client
|
||||
|
||||
async def initial_login(self, username: str, password: str) -> Optional[GarminCredentials]:
|
||||
async def initial_login(
|
||||
self, username: str, password: str
|
||||
) -> Optional[GarminCredentials]:
|
||||
"""Performs initial login to Garmin Connect and returns GarminCredentials."""
|
||||
try:
|
||||
client = await self._perform_login(username, password) # Use the retried login helper
|
||||
garmin_client = await self._perform_login(
|
||||
username, password
|
||||
) # Use the retried login helper
|
||||
if not garmin_client:
|
||||
return None
|
||||
|
||||
logger.info(f"Successful Garmin login for {username}")
|
||||
|
||||
return GarminCredentials(
|
||||
# Extract tokens and cookies
|
||||
garmin_credentials = GarminCredentials(
|
||||
garmin_username=username,
|
||||
garmin_password_plaintext=password, # Storing plaintext as per user requirement
|
||||
garmin_password_plaintext=password, # Storing plaintext for re-auth, consider encryption
|
||||
display_name=garmin_client.display_name,
|
||||
full_name=garmin_client.full_name,
|
||||
unit_system=garmin_client.unit_system,
|
||||
token_dict=garmin_client.garth.dump(), # Use garth.dump() to get the token dictionary
|
||||
)
|
||||
return garmin_credentials
|
||||
except Exception as e:
|
||||
logger.error(f"Garmin initial login failed for {username}: {e}")
|
||||
return None
|
||||
return None
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import logging
|
||||
from datetime import datetime # Import datetime
|
||||
from typing import Optional
|
||||
from datetime import datetime # Import datetime
|
||||
|
||||
from garminconnect import Garmin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GarminClientService:
|
||||
def __init__(self):
|
||||
self.client: Optional[Garmin] = None
|
||||
@@ -18,7 +19,7 @@ class GarminClientService:
|
||||
if self.username != username or self.password != password:
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.client = None # Invalidate existing client if credentials change
|
||||
self.client = None # Invalidate existing client if credentials change
|
||||
|
||||
def authenticate(self) -> bool:
|
||||
"""Authenticates with Garmin Connect using stored credentials, or reuses existing client."""
|
||||
@@ -30,25 +31,37 @@ class GarminClientService:
|
||||
try:
|
||||
self.client = Garmin(self.username, self.password)
|
||||
self.client.login()
|
||||
logger.info(f"Successfully authenticated Garmin client for {self.username}.")
|
||||
logger.info(
|
||||
f"Successfully authenticated Garmin client for {self.username}."
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to authenticate Garmin client for {self.username}: {e}")
|
||||
logger.error(
|
||||
f"Failed to authenticate Garmin client for {self.username}: {e}"
|
||||
)
|
||||
self.client = None
|
||||
return False
|
||||
else:
|
||||
# If client exists, assume it's authenticated and check session validity
|
||||
if self.check_session_validity():
|
||||
logger.debug(f"Garmin client already authenticated for {self.username}. Reusing existing client.")
|
||||
logger.debug(
|
||||
f"Garmin client already authenticated for {self.username}. Reusing existing client."
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.info(f"Existing Garmin client session for {self.username} is invalid. Attempting to re-login.")
|
||||
logger.info(
|
||||
f"Existing Garmin client session for {self.username} is invalid. Attempting to re-login."
|
||||
)
|
||||
try:
|
||||
self.client.login() # Attempt to re-login with existing client
|
||||
logger.info(f"Successfully re-logged in Garmin client for {self.username}.")
|
||||
self.client.login() # Attempt to re-login with existing client
|
||||
logger.info(
|
||||
f"Successfully re-logged in Garmin client for {self.username}."
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to re-login Garmin client for {self.username}: {e}")
|
||||
logger.error(
|
||||
f"Failed to re-login Garmin client for {self.username}: {e}"
|
||||
)
|
||||
self.client = None
|
||||
return False
|
||||
|
||||
@@ -59,24 +72,27 @@ class GarminClientService:
|
||||
|
||||
def get_client(self) -> Garmin:
|
||||
"""Returns the authenticated Garmin client instance."""
|
||||
if self.client is None: # Check self.client directly
|
||||
raise Exception("Garmin client not initialized or authenticated. Call authenticate first.")
|
||||
if self.client is None: # Check self.client directly
|
||||
raise Exception(
|
||||
"Garmin client not initialized or authenticated. Call authenticate first."
|
||||
)
|
||||
return self.client
|
||||
|
||||
def check_session_validity(self) -> bool:
|
||||
"""
|
||||
Checks if the current Garmin session is still valid by making a lightweight API call.
|
||||
"""
|
||||
if self.client is None: # Check self.client directly
|
||||
if self.client is None: # Check self.client directly
|
||||
return False
|
||||
try:
|
||||
self.client.get_user_summary(datetime.now().isoformat().split('T')[0])
|
||||
self.client.get_user_summary(datetime.now().isoformat().split("T")[0])
|
||||
logger.debug(f"Garmin session is still valid for {self.username}.")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Garmin session became invalid for {self.username}: {e}")
|
||||
self.client = None # Invalidate client on session failure
|
||||
self.client = None # Invalidate client on session failure
|
||||
return False
|
||||
|
||||
|
||||
# Global instance for dependency injection
|
||||
garmin_client_service = GarminClientService()
|
||||
|
||||
@@ -3,9 +3,13 @@ import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
||||
from tenacity import (
|
||||
retry,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from ..jobs import job_store
|
||||
from ..services.central_db_service import CentralDBService
|
||||
from ..services.garmin_auth_service import GarminAuthService
|
||||
from ..services.garmin_client_service import GarminClientService
|
||||
@@ -16,22 +20,25 @@ logger = logging.getLogger(__name__)
|
||||
GARMIN_RETRY_STRATEGY = retry(
|
||||
stop=stop_after_attempt(5),
|
||||
wait=wait_exponential(multiplier=1, min=4, max=10),
|
||||
retry=retry_if_exception_type(Exception), # Broad exception for now, refine later
|
||||
reraise=True
|
||||
retry=retry_if_exception_type(Exception), # Broad exception for now, refine later
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
|
||||
class GarminHealthService:
|
||||
def __init__(
|
||||
self,
|
||||
garmin_client_service: GarminClientService,
|
||||
central_db_service: CentralDBService,
|
||||
garmin_auth_service: GarminAuthService
|
||||
garmin_auth_service: GarminAuthService,
|
||||
):
|
||||
self.garmin_client_service = garmin_client_service
|
||||
self.central_db_service = central_db_service
|
||||
self.garmin_auth_service = garmin_auth_service
|
||||
|
||||
async def _get_authenticated_garmin_client(self, user_id: int) -> Optional[GarminClientService]:
|
||||
async def _get_authenticated_garmin_client(
|
||||
self, user_id: int
|
||||
) -> Optional[GarminClientService]:
|
||||
credentials = await self.central_db_service.get_garmin_credentials(user_id)
|
||||
if not credentials:
|
||||
logger.error(f"No Garmin credentials found for user {user_id}.")
|
||||
@@ -46,18 +53,29 @@ class GarminHealthService:
|
||||
# Check if the client is authenticated after updating credentials
|
||||
if not self.garmin_client_service.is_authenticated():
|
||||
if not self.garmin_client_service.authenticate():
|
||||
logger.error(f"Failed to authenticate Garmin client for user {user_id}.")
|
||||
logger.error(
|
||||
f"Failed to authenticate Garmin client for user {user_id}."
|
||||
)
|
||||
return None
|
||||
|
||||
return self.garmin_client_service
|
||||
|
||||
@GARMIN_RETRY_STRATEGY
|
||||
async def download_and_save_health_metric(
|
||||
self, metric_data: Dict[str, Any],
|
||||
garmin_client: Optional[GarminClientService] = None # New argument
|
||||
self,
|
||||
metric_data: Dict[str, Any],
|
||||
garmin_client: Optional[GarminClientService] = None, # New argument
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
_garmin_client = garmin_client or await self._get_authenticated_garmin_client(user_id)
|
||||
# user_id is not directly available here, assuming it's handled by the caller or context
|
||||
# For now, we'll assume the garmin_client is already authenticated for the correct user
|
||||
# _garmin_client = garmin_client or await self._get_authenticated_garmin_client(user_id)
|
||||
_garmin_client = (
|
||||
garmin_client # Assuming garmin_client is passed and authenticated
|
||||
)
|
||||
if not _garmin_client:
|
||||
logger.error(
|
||||
"Garmin client not provided or authenticated for health metric download."
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -66,14 +84,18 @@ class GarminHealthService:
|
||||
value = metric_data.get("value")
|
||||
|
||||
if not all([metric_type, timestamp, value]):
|
||||
logger.warning(f"Skipping health metric due to missing data: {metric_data}")
|
||||
logger.warning(
|
||||
f"Skipping health metric due to missing data: {metric_data}"
|
||||
)
|
||||
return None
|
||||
|
||||
if isinstance(timestamp, str):
|
||||
try:
|
||||
timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
logger.error(f"Invalid timestamp format for health metric: {timestamp}")
|
||||
logger.error(
|
||||
f"Invalid timestamp format for health metric: {timestamp}"
|
||||
)
|
||||
return None
|
||||
|
||||
metric_data["timestamp"] = timestamp
|
||||
@@ -85,56 +107,71 @@ class GarminHealthService:
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error downloading and saving health metric {metric_data}: {e}",
|
||||
exc_info=True
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
async def sync_health_metrics_in_background(
|
||||
self,
|
||||
job_id: str,
|
||||
user_id: int,
|
||||
current_sync_job_manager,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
):
|
||||
user_id = 1 # Assuming single user for now
|
||||
try:
|
||||
job_store.update_job(job_id, status="in_progress", progress=0.0)
|
||||
# user_id = 1 # Assuming single user for now - now passed as argument
|
||||
|
||||
# Authenticate Garmin client once at the beginning
|
||||
garmin_client = await self._get_authenticated_garmin_client(user_id)
|
||||
if not garmin_client:
|
||||
raise Exception("Garmin client not authenticated or failed to get valid credentials.")
|
||||
raise Exception(
|
||||
"Garmin client not authenticated or failed to get valid credentials."
|
||||
)
|
||||
|
||||
_start_date = start_date or date(2000, 1, 1)
|
||||
_end_date = end_date or date.today()
|
||||
|
||||
date_range = [_start_date + timedelta(days=x) for x in range((_end_date - _start_date).days + 1)]
|
||||
date_range = [
|
||||
_start_date + timedelta(days=x)
|
||||
for x in range((_end_date - _start_date).days + 1)
|
||||
]
|
||||
|
||||
summary_tasks = [
|
||||
GARMIN_RETRY_STRATEGY(garmin_client.get_client().get_daily_summary)(d.isoformat())
|
||||
GARMIN_RETRY_STRATEGY(garmin_client.get_client().get_daily_summary)(
|
||||
d.isoformat()
|
||||
)
|
||||
for d in date_range
|
||||
]
|
||||
daily_summaries = await asyncio.gather(*summary_tasks, return_exceptions=True)
|
||||
daily_summaries = await asyncio.gather(
|
||||
*summary_tasks, return_exceptions=True
|
||||
)
|
||||
|
||||
all_metrics_data = []
|
||||
for i, summary in enumerate(daily_summaries):
|
||||
if isinstance(summary, Exception):
|
||||
logger.warning(f"Could not fetch daily summary for {date_range[i]}: {summary}")
|
||||
logger.warning(
|
||||
f"Could not fetch daily summary for {date_range[i]}: {summary}"
|
||||
)
|
||||
continue
|
||||
if summary:
|
||||
if "heartRate" in summary:
|
||||
all_metrics_data.append({
|
||||
"type": "heart_rate",
|
||||
"timestamp": summary["calendarDate"],
|
||||
"value": summary["heartRate"].get("restingHeartRate"),
|
||||
"unit": "bpm"
|
||||
})
|
||||
all_metrics_data.append(
|
||||
{
|
||||
"type": "heart_rate",
|
||||
"timestamp": summary["calendarDate"],
|
||||
"value": summary["heartRate"].get("restingHeartRate"),
|
||||
"unit": "bpm",
|
||||
}
|
||||
)
|
||||
if "stress" in summary:
|
||||
all_metrics_data.append({
|
||||
"type": "stress_score",
|
||||
"timestamp": summary["calendarDate"],
|
||||
"value": summary["stress"].get("overallStressLevel"),
|
||||
"unit": "score"
|
||||
})
|
||||
all_metrics_data.append(
|
||||
{
|
||||
"type": "stress_score",
|
||||
"timestamp": summary["calendarDate"],
|
||||
"value": summary["stress"].get("overallStressLevel"),
|
||||
"unit": "score",
|
||||
}
|
||||
)
|
||||
|
||||
total_metrics = len(all_metrics_data)
|
||||
synced_count = 0
|
||||
@@ -142,7 +179,7 @@ class GarminHealthService:
|
||||
metric_save_tasks = [
|
||||
self.download_and_save_health_metric(
|
||||
metric_data=metric,
|
||||
garmin_client=garmin_client # Pass the authenticated client
|
||||
garmin_client=garmin_client, # Pass the authenticated client
|
||||
)
|
||||
for metric in all_metrics_data
|
||||
]
|
||||
@@ -150,24 +187,19 @@ class GarminHealthService:
|
||||
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"Failed to save health metric {all_metrics_data[i]}: {result}")
|
||||
logger.error(
|
||||
f"Failed to save health metric {all_metrics_data[i]}: {result}"
|
||||
)
|
||||
else:
|
||||
synced_count += 1
|
||||
job_store.update_job(job_id, status="in_progress", progress=(i + 1) / total_metrics)
|
||||
await current_sync_job_manager.update_progress(
|
||||
progress=(i + 1) / total_metrics
|
||||
)
|
||||
|
||||
job_store.update_job(
|
||||
job_id,
|
||||
status="completed",
|
||||
progress=1.0,
|
||||
details={
|
||||
"synced_health_metrics_count": synced_count,
|
||||
"total_health_metrics_found": total_metrics
|
||||
}
|
||||
)
|
||||
await current_sync_job_manager.complete_sync()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error during health metrics synchronization for job {job_id}: {e}",
|
||||
exc_info=True
|
||||
f"Error during health metrics synchronization: {e}", exc_info=True
|
||||
)
|
||||
job_store.update_job(job_id, status="failed", progress=1.0, error_message=str(e))
|
||||
await current_sync_job_manager.fail_sync(error_message=str(e))
|
||||
|
||||
@@ -2,9 +2,13 @@ import logging
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
||||
from tenacity import (
|
||||
retry,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from ..jobs import job_store
|
||||
from ..services.garmin_client_service import GarminClientService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -13,10 +17,11 @@ logger = logging.getLogger(__name__)
|
||||
GARMIN_RETRY_STRATEGY = retry(
|
||||
stop=stop_after_attempt(5),
|
||||
wait=wait_exponential(multiplier=1, min=4, max=10),
|
||||
retry=retry_if_exception_type(Exception), # Broad exception for now, refine later
|
||||
reraise=True
|
||||
retry=retry_if_exception_type(Exception), # Broad exception for now, refine later
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
|
||||
class GarminWorkoutService:
|
||||
def __init__(self, garmin_client_service: GarminClientService):
|
||||
self.garmin_client_service = garmin_client_service
|
||||
@@ -27,8 +32,11 @@ class GarminWorkoutService:
|
||||
# Get workout from CentralDB
|
||||
from ..config import settings
|
||||
from .central_db_service import CentralDBService
|
||||
|
||||
central_db = CentralDBService(base_url=settings.CENTRAL_DB_URL)
|
||||
workout = await central_db.get_workout_by_id(workout_id) # Assuming this method exists
|
||||
workout = await central_db.get_workout_by_id(
|
||||
workout_id
|
||||
) # Assuming this method exists
|
||||
|
||||
if not workout:
|
||||
logger.error(f"Workout with ID {workout_id} not found in CentralDB.")
|
||||
@@ -42,7 +50,7 @@ class GarminWorkoutService:
|
||||
f"Simulating upload of workout {workout.name} (ID: {workout_id}) "
|
||||
"to Garmin Connect."
|
||||
)
|
||||
garmin_workout_id = f"GARMIN_WORKOUT_{workout_id}" # Mock ID
|
||||
garmin_workout_id = f"GARMIN_WORKOUT_{workout_id}" # Mock ID
|
||||
|
||||
# Here we would update the workout in CentralDB with the garmin_workout_id
|
||||
# await central_db.update_workout(
|
||||
@@ -68,34 +76,21 @@ class GarminWorkoutService:
|
||||
|
||||
async def upload_workout_in_background(
|
||||
self,
|
||||
job_id: str,
|
||||
user_id: int,
|
||||
current_sync_job_manager,
|
||||
workout_id: uuid.UUID,
|
||||
):
|
||||
try:
|
||||
job_store.update_job(job_id, status="in_progress", progress=0.0)
|
||||
|
||||
uploaded_workout = await self.upload_workout(workout_id)
|
||||
|
||||
if uploaded_workout:
|
||||
job_store.update_job(
|
||||
job_id,
|
||||
status="completed",
|
||||
progress=1.0,
|
||||
details={
|
||||
"uploaded_workout_id": str(uploaded_workout.id),
|
||||
"garmin_workout_id": uploaded_workout.garmin_workout_id
|
||||
}
|
||||
)
|
||||
await current_sync_job_manager.complete_sync()
|
||||
else:
|
||||
job_store.update_job(
|
||||
job_id,
|
||||
status="failed",
|
||||
progress=1.0,
|
||||
await current_sync_job_manager.fail_sync(
|
||||
error_message=f"Failed to upload workout {workout_id}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error during workout upload for job {job_id}: {e}", exc_info=True
|
||||
)
|
||||
job_store.update_job(job_id, status="failed", progress=1.0, error_message=str(e))
|
||||
logger.error(f"Error during workout upload: {e}", exc_info=True)
|
||||
await current_sync_job_manager.fail_sync(error_message=str(e))
|
||||
|
||||
@@ -19,5 +19,5 @@ class RateLimiter:
|
||||
if not self.limiter.test(self.rate_limit_item, client_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Rate limit exceeded. Please try again later."
|
||||
detail="Rate limit exceeded. Please try again later.",
|
||||
)
|
||||
|
||||
59
backend/src/services/sync_manager.py
Normal file
59
backend/src/services/sync_manager.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from ..models.sync_job import SyncJob, SyncJobType
|
||||
|
||||
|
||||
class CurrentSyncJobManager:
|
||||
_instance = None
|
||||
_lock = asyncio.Lock()
|
||||
_current_job: Optional[SyncJob] = None
|
||||
|
||||
def __new__(cls) -> CurrentSyncJobManager:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
async def start_sync(self, job_type: SyncJobType) -> SyncJob:
|
||||
async with self._lock:
|
||||
if self._current_job and self._current_job.status == "in_progress":
|
||||
raise RuntimeError("A sync job is already in progress.")
|
||||
self._current_job = SyncJob(
|
||||
job_type=job_type,
|
||||
status="in_progress",
|
||||
start_time=datetime.now(),
|
||||
)
|
||||
return self._current_job
|
||||
|
||||
async def update_progress(self, progress: float) -> None:
|
||||
async with self._lock:
|
||||
if self._current_job:
|
||||
self._current_job.progress = progress
|
||||
|
||||
async def complete_sync(self) -> None:
|
||||
async with self._lock:
|
||||
if self._current_job:
|
||||
self._current_job.status = "completed"
|
||||
self._current_job.end_time = datetime.now()
|
||||
self._current_job.progress = 1.0
|
||||
|
||||
async def fail_sync(self, error_message: str) -> None:
|
||||
async with self._lock:
|
||||
if self._current_job:
|
||||
self._current_job.status = "failed"
|
||||
self._current_job.end_time = datetime.now()
|
||||
self._current_job.error_message = error_message
|
||||
|
||||
async def get_current_sync_status(self) -> Optional[SyncJob]:
|
||||
async with self._lock:
|
||||
return self._current_job
|
||||
|
||||
async def is_sync_active(self) -> bool:
|
||||
async with self._lock:
|
||||
return self._current_job and self._current_job.status == "in_progress"
|
||||
|
||||
|
||||
current_sync_job_manager = CurrentSyncJobManager()
|
||||
@@ -1,28 +0,0 @@
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from ..jobs import JobStore, SyncJob
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SyncStatusService:
|
||||
def __init__(self, job_store: JobStore):
|
||||
self.job_store = job_store
|
||||
|
||||
def get_sync_jobs(
|
||||
self,
|
||||
job_id: Optional[UUID] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SyncJob]:
|
||||
try:
|
||||
all_jobs = self.job_store.get_all_jobs()
|
||||
|
||||
if job_id:
|
||||
all_jobs = [job for job in all_jobs if job.id == str(job_id)]
|
||||
|
||||
return all_jobs[offset:offset+limit]
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving sync jobs: {e}", exc_info=True)
|
||||
return []
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
def calculate_sha256(file_path: Path) -> str:
|
||||
"""Calculate the SHA256 checksum of a file."""
|
||||
hasher = hashlib.sha256()
|
||||
with open(file_path, 'rb') as f:
|
||||
with open(file_path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(8192) # Read in 8KB chunks
|
||||
if not chunk:
|
||||
|
||||
@@ -2,25 +2,30 @@ from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from backend.src.main import app
|
||||
from backend.src.schemas import GarminCredentials
|
||||
from httpx import AsyncClient
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.main import app
|
||||
from src.schemas import GarminCredentials
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_garmin_auth_service():
|
||||
with patch('backend.src.api.garmin_auth.GarminAuthService') as MockGarminAuthService:
|
||||
with patch("src.api.garmin_auth.GarminAuthService") as MockGarminAuthService:
|
||||
service_instance = MockGarminAuthService.return_value
|
||||
yield service_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_central_db_service():
|
||||
with patch('backend.src.api.garmin_auth.CentralDBService') as MockCentralDBService:
|
||||
with patch("src.api.garmin_auth.CentralDBService") as MockCentralDBService:
|
||||
service_instance = MockCentralDBService.return_value
|
||||
yield service_instance
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_garmin_login_success_new_credentials(mock_garmin_auth_service, mock_central_db_service):
|
||||
async def test_garmin_login_success_new_credentials(
|
||||
mock_garmin_auth_service, mock_central_db_service
|
||||
):
|
||||
username = "test@example.com"
|
||||
password = "password123"
|
||||
|
||||
@@ -29,28 +34,33 @@ async def test_garmin_login_success_new_credentials(mock_garmin_auth_service, mo
|
||||
garmin_password_plaintext=password,
|
||||
access_token="mock_access_token",
|
||||
access_token_secret="mock_access_token_secret",
|
||||
token_expiration_date=datetime.utcnow() + timedelta(hours=1)
|
||||
token_expiration_date=datetime.utcnow() + timedelta(hours=1),
|
||||
)
|
||||
mock_central_db_service.get_garmin_credentials.return_value = None # No existing credentials
|
||||
mock_central_db_service.create_garmin_credentials.return_value = AsyncMock() # Simulate successful creation
|
||||
mock_central_db_service.get_garmin_credentials.return_value = (
|
||||
None # No existing credentials
|
||||
)
|
||||
mock_central_db_service.create_garmin_credentials.return_value = (
|
||||
AsyncMock()
|
||||
) # Simulate successful creation
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/garmin/login",
|
||||
json={
|
||||
"username": username,
|
||||
"password": password
|
||||
}
|
||||
with TestClient(app=app) as client:
|
||||
response = client.post(
|
||||
"/api/garmin/login", json={"username": username, "password": password}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Garmin account linked successfully."}
|
||||
mock_garmin_auth_service.initial_login.assert_called_once_with(username, password)
|
||||
mock_central_db_service.get_garmin_credentials.assert_called_once_with(1) # Assuming user_id 1
|
||||
mock_central_db_service.get_garmin_credentials.assert_called_once_with(
|
||||
1
|
||||
) # Assuming user_id 1
|
||||
mock_central_db_service.create_garmin_credentials.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_garmin_login_success_update_credentials(mock_garmin_auth_service, mock_central_db_service):
|
||||
async def test_garmin_login_success_update_credentials(
|
||||
mock_garmin_auth_service, mock_central_db_service
|
||||
):
|
||||
username = "test@example.com"
|
||||
password = "password123"
|
||||
|
||||
@@ -59,24 +69,22 @@ async def test_garmin_login_success_update_credentials(mock_garmin_auth_service,
|
||||
garmin_password_plaintext=password,
|
||||
access_token="mock_access_token_new",
|
||||
access_token_secret="mock_access_token_secret_new",
|
||||
token_expiration_date=datetime.utcnow() + timedelta(hours=1)
|
||||
token_expiration_date=datetime.utcnow() + timedelta(hours=1),
|
||||
)
|
||||
mock_central_db_service.get_garmin_credentials.return_value = GarminCredentials(
|
||||
garmin_username=username,
|
||||
garmin_password_plaintext="old_password",
|
||||
access_token="old_access_token",
|
||||
access_token_secret="old_access_token_secret",
|
||||
token_expiration_date=datetime.utcnow() - timedelta(hours=1)
|
||||
) # Existing credentials
|
||||
mock_central_db_service.update_garmin_credentials.return_value = AsyncMock() # Simulate successful update
|
||||
token_expiration_date=datetime.utcnow() - timedelta(hours=1),
|
||||
) # Existing credentials
|
||||
mock_central_db_service.update_garmin_credentials.return_value = (
|
||||
AsyncMock()
|
||||
) # Simulate successful update
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/garmin/login",
|
||||
json={
|
||||
"username": username,
|
||||
"password": password
|
||||
}
|
||||
with TestClient(app=app) as client:
|
||||
response = client.post(
|
||||
"/api/garmin/login", json={"username": username, "password": password}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -85,20 +93,21 @@ async def test_garmin_login_success_update_credentials(mock_garmin_auth_service,
|
||||
mock_central_db_service.get_garmin_credentials.assert_called_once_with(1)
|
||||
mock_central_db_service.update_garmin_credentials.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_garmin_login_failure_invalid_credentials(mock_garmin_auth_service, mock_central_db_service):
|
||||
async def test_garmin_login_failure_invalid_credentials(
|
||||
mock_garmin_auth_service, mock_central_db_service
|
||||
):
|
||||
username = "invalid@example.com"
|
||||
password = "wrongpassword"
|
||||
|
||||
mock_garmin_auth_service.initial_login.return_value = None # Simulate failed Garmin login
|
||||
mock_garmin_auth_service.initial_login.return_value = (
|
||||
None # Simulate failed Garmin login
|
||||
)
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/garmin/login",
|
||||
json={
|
||||
"username": username,
|
||||
"password": password
|
||||
}
|
||||
with TestClient(app=app) as client:
|
||||
response = client.post(
|
||||
"/api/garmin/login", json={"username": username, "password": password}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
@@ -108,8 +117,11 @@ async def test_garmin_login_failure_invalid_credentials(mock_garmin_auth_service
|
||||
mock_central_db_service.create_garmin_credentials.assert_not_called()
|
||||
mock_central_db_service.update_garmin_credentials.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_garmin_login_failure_central_db_create_error(mock_garmin_auth_service, mock_central_db_service):
|
||||
async def test_garmin_login_failure_central_db_create_error(
|
||||
mock_garmin_auth_service, mock_central_db_service
|
||||
):
|
||||
username = "test@example.com"
|
||||
password = "password123"
|
||||
|
||||
@@ -118,25 +130,28 @@ async def test_garmin_login_failure_central_db_create_error(mock_garmin_auth_ser
|
||||
garmin_password_plaintext=password,
|
||||
access_token="mock_access_token",
|
||||
access_token_secret="mock_access_token_secret",
|
||||
token_expiration_date=datetime.utcnow() + timedelta(hours=1)
|
||||
token_expiration_date=datetime.utcnow() + timedelta(hours=1),
|
||||
)
|
||||
mock_central_db_service.get_garmin_credentials.return_value = None
|
||||
mock_central_db_service.create_garmin_credentials.return_value = None # Simulate CentralDB create failure
|
||||
mock_central_db_service.create_garmin_credentials.return_value = (
|
||||
None # Simulate CentralDB create failure
|
||||
)
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/garmin/login",
|
||||
json={
|
||||
"username": username,
|
||||
"password": password
|
||||
}
|
||||
with TestClient(app=app) as client:
|
||||
response = client.post(
|
||||
"/api/garmin/login", json={"username": username, "password": password}
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"detail": "Failed to store Garmin credentials in CentralDB."}
|
||||
assert response.json() == {
|
||||
"detail": "Failed to store Garmin credentials in CentralDB."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_garmin_login_failure_central_db_update_error(mock_garmin_auth_service, mock_central_db_service):
|
||||
async def test_garmin_login_failure_central_db_update_error(
|
||||
mock_garmin_auth_service, mock_central_db_service
|
||||
):
|
||||
username = "test@example.com"
|
||||
password = "password123"
|
||||
|
||||
@@ -145,25 +160,25 @@ async def test_garmin_login_failure_central_db_update_error(mock_garmin_auth_ser
|
||||
garmin_password_plaintext=password,
|
||||
access_token="mock_access_token_new",
|
||||
access_token_secret="mock_access_token_secret_new",
|
||||
token_expiration_date=datetime.utcnow() + timedelta(hours=1)
|
||||
token_expiration_date=datetime.utcnow() + timedelta(hours=1),
|
||||
)
|
||||
mock_central_db_service.get_garmin_credentials.return_value = GarminCredentials(
|
||||
garmin_username=username,
|
||||
garmin_password_plaintext="old_password",
|
||||
access_token="old_access_token",
|
||||
access_token_secret="old_access_token_secret",
|
||||
token_expiration_date=datetime.utcnow() - timedelta(hours=1)
|
||||
token_expiration_date=datetime.utcnow() - timedelta(hours=1),
|
||||
)
|
||||
mock_central_db_service.update_garmin_credentials.return_value = (
|
||||
None # Simulate CentralDB update failure
|
||||
)
|
||||
mock_central_db_service.update_garmin_credentials.return_value = None # Simulate CentralDB update failure
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/garmin/login",
|
||||
json={
|
||||
"username": username,
|
||||
"password": password
|
||||
}
|
||||
with TestClient(app=app) as client:
|
||||
response = client.post(
|
||||
"/api/garmin/login", json={"username": username, "password": password}
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"detail": "Failed to update Garmin credentials in CentralDB."}
|
||||
assert response.json() == {
|
||||
"detail": "Failed to update Garmin credentials in CentralDB."
|
||||
}
|
||||
|
||||
@@ -1,80 +1,88 @@
|
||||
from datetime import date
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from backend.src.main import app
|
||||
from backend.src.schemas import User
|
||||
from fastapi import HTTPException
|
||||
from httpx import AsyncClient
|
||||
from backend.src.services.sync_manager import current_sync_job_manager
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_garmin_activity_service():
|
||||
with patch('backend.src.api.garmin_sync.GarminActivityService') as MockGarminActivityService:
|
||||
service_instance = MockGarminActivityService.return_value
|
||||
yield service_instance
|
||||
def test_get_sync_status():
|
||||
response = client.get("/api/sync/garmin/sync/status")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_current_user():
|
||||
with patch('backend.src.api.garmin_sync.get_current_user') as mock_current_user:
|
||||
mock_current_user.return_value = User(id=1, name="Test User", email="test@example.com")
|
||||
yield mock_current_user
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_garmin_activity_sync_success(mock_garmin_activity_service, mock_get_current_user):
|
||||
mock_garmin_activity_service.sync_activities_in_background = AsyncMock()
|
||||
mock_garmin_activity_service.sync_activities_in_background.return_value = None
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/sync/garmin/activities",
|
||||
json={
|
||||
"force_resync": False,
|
||||
"start_date": "2023-01-01",
|
||||
"end_date": "2023-01-31"
|
||||
}
|
||||
)
|
||||
|
||||
def test_trigger_activity_sync_success():
|
||||
response = client.post("/api/sync/garmin/activities", json={})
|
||||
assert response.status_code == 202
|
||||
response_json = response.json()
|
||||
assert "job_id" in response_json
|
||||
assert "status" in response_json
|
||||
assert response_json["status"] == "pending"
|
||||
mock_garmin_activity_service.sync_activities_in_background.assert_called_once()
|
||||
args, kwargs = mock_garmin_activity_service.sync_activities_in_background.call_args
|
||||
assert not args[1] # force_resync
|
||||
assert args[2] == date(2023, 1, 1) # start_date
|
||||
assert args[3] == date(2023, 1, 31) # end_date
|
||||
assert response.json() == {
|
||||
"message": "Activity synchronization initiated successfully."
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_garmin_activity_sync_no_dates(mock_garmin_activity_service, mock_get_current_user):
|
||||
mock_garmin_activity_service.sync_activities_in_background = AsyncMock()
|
||||
mock_garmin_activity_service.sync_activities_in_background.return_value = None
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/sync/garmin/activities",
|
||||
json={}
|
||||
)
|
||||
def test_trigger_activity_sync_conflict():
|
||||
# Manually start a sync to simulate a conflict
|
||||
current_sync_job_manager._current_job = current_sync_job_manager.start_sync(
|
||||
"activities"
|
||||
)
|
||||
|
||||
response = client.post("/api/sync/garmin/activities", json={})
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"detail": "A synchronization is already in progress. Please wait or check status."
|
||||
}
|
||||
|
||||
# Clean up
|
||||
current_sync_job_manager._current_job = None
|
||||
|
||||
|
||||
def test_trigger_workout_sync_success():
|
||||
response = client.post(
|
||||
"/api/sync/garmin/workouts",
|
||||
json={"workout_id": "a1b2c3d4-e5f6-7890-1234-567890abcdef"},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
response_json = response.json()
|
||||
assert "job_id" in response_json
|
||||
assert "status" in response_json
|
||||
assert response_json["status"] == "pending"
|
||||
mock_garmin_activity_service.sync_activities_in_background.assert_called_once()
|
||||
args, kwargs = mock_garmin_activity_service.sync_activities_in_background.call_args
|
||||
assert not args[1] # force_resync
|
||||
assert args[2] is None # start_date
|
||||
assert args[3] is None # end_date
|
||||
assert response.json() == {
|
||||
"message": "Workout synchronization initiated successfully."
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_garmin_activity_sync_unauthorized():
|
||||
with patch('backend.src.api.garmin_sync.get_current_user', side_effect=HTTPException(status_code=401)):
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/sync/garmin/activities",
|
||||
json={}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Not Authenticated"} # Default FastAPI 401 detail
|
||||
|
||||
def test_trigger_workout_sync_conflict():
|
||||
# Manually start a sync to simulate a conflict
|
||||
current_sync_job_manager._current_job = current_sync_job_manager.start_sync(
|
||||
"workouts"
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/sync/garmin/workouts",
|
||||
json={"workout_id": "a1b2c3d4-e5f6-7890-1234-567890abcdef"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"detail": "A synchronization is already in progress. Please wait or check status."
|
||||
}
|
||||
|
||||
# Clean up
|
||||
current_sync_job_manager._current_job = None
|
||||
|
||||
|
||||
def test_trigger_health_sync_success():
|
||||
response = client.post("/api/sync/garmin/health", json={})
|
||||
assert response.status_code == 202
|
||||
assert response.json() == {
|
||||
"message": "Health metrics synchronization initiated successfully."
|
||||
}
|
||||
|
||||
|
||||
def test_trigger_health_sync_conflict():
|
||||
# Manually start a sync to simulate a conflict
|
||||
current_sync_job_manager._current_job = current_sync_job_manager.start_sync(
|
||||
"health"
|
||||
)
|
||||
|
||||
response = client.post("/api/sync/garmin/health", json={})
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"detail": "A synchronization is already in progress. Please wait or check status."
|
||||
}
|
||||
|
||||
# Clean up
|
||||
current_sync_job_manager._current_job = None
|
||||
|
||||
@@ -1,40 +1,44 @@
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from backend.src.schemas import GarminCredentials
|
||||
from backend.src.services.garmin_activity_service import GarminActivityService
|
||||
from backend.src.services.garmin_health_service import GarminHealthService
|
||||
|
||||
from src.schemas import GarminCredentials
|
||||
from src.services.garmin_activity_service import GarminActivityService
|
||||
from src.services.garmin_health_service import GarminHealthService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_garmin_auth_service_instance():
|
||||
with patch(
|
||||
'backend.src.services.garmin_activity_service.GarminAuthService'
|
||||
"src.services.garmin_activity_service.GarminAuthService"
|
||||
) as MockGarminAuthService:
|
||||
instance = MockGarminAuthService.return_value
|
||||
yield instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_central_db_service_instance():
|
||||
with patch(
|
||||
'backend.src.services.garmin_activity_service.CentralDBService'
|
||||
"src.services.garmin_activity_service.CentralDBService"
|
||||
) as MockCentralDBService:
|
||||
service_instance = MockCentralDBService.return_value
|
||||
yield service_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_garmin_client_service_instance():
|
||||
with patch(
|
||||
'backend.src.services.garmin_activity_service.GarminClientService'
|
||||
"src.services.garmin_activity_service.GarminClientService"
|
||||
) as MockGarminClientService:
|
||||
instance = MockGarminClientService.return_value
|
||||
yield instance
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_garmin_activity_sync_authentication_flow(
|
||||
mock_garmin_auth_service_instance,
|
||||
mock_central_db_service_instance,
|
||||
mock_garmin_client_service_instance
|
||||
mock_garmin_client_service_instance,
|
||||
):
|
||||
user_id = 1
|
||||
username = "test@example.com"
|
||||
@@ -42,10 +46,11 @@ async def test_garmin_activity_sync_authentication_flow(
|
||||
|
||||
# Mock GarminCredentials from CentralDB
|
||||
mock_credentials = GarminCredentials(
|
||||
garmin_username=username,
|
||||
garmin_password_plaintext=password
|
||||
garmin_username=username, garmin_password_plaintext=password
|
||||
)
|
||||
mock_central_db_service_instance.get_garmin_credentials.return_value = (
|
||||
mock_credentials
|
||||
)
|
||||
mock_central_db_service_instance.get_garmin_credentials.return_value = mock_credentials
|
||||
|
||||
# Mock GarminClientService authentication
|
||||
mock_garmin_client_service_instance.is_authenticated.return_value = False
|
||||
@@ -53,31 +58,40 @@ async def test_garmin_activity_sync_authentication_flow(
|
||||
|
||||
# Mock GarminClientService.get_client().get_activities
|
||||
mock_garmin_client_instance = AsyncMock()
|
||||
mock_garmin_client_service_instance.get_client.return_value = mock_garmin_client_instance
|
||||
mock_garmin_client_instance.get_activities.return_value = [] # Simulate no activities
|
||||
mock_garmin_client_service_instance.get_client.return_value = (
|
||||
mock_garmin_client_instance
|
||||
)
|
||||
mock_garmin_client_instance.get_activities.return_value = (
|
||||
[]
|
||||
) # Simulate no activities
|
||||
|
||||
activity_service = GarminActivityService(
|
||||
garmin_client_service=mock_garmin_client_service_instance,
|
||||
activity_download_service=AsyncMock(), # Mock this dependency
|
||||
garmin_auth_service=mock_garmin_auth_service_instance, # Still needed for init, but methods not called
|
||||
central_db_service=mock_central_db_service_instance
|
||||
activity_download_service=AsyncMock(), # Mock this dependency
|
||||
garmin_auth_service=mock_garmin_auth_service_instance, # Still needed for init, but methods not called
|
||||
central_db_service=mock_central_db_service_instance,
|
||||
)
|
||||
|
||||
# Call sync_activities_in_background, which will trigger authentication
|
||||
await activity_service.sync_activities_in_background(job_id="test_job")
|
||||
await activity_service.sync_activities_in_background(user_id=user_id)
|
||||
|
||||
# Assertions
|
||||
mock_central_db_service_instance.get_garmin_credentials.assert_called_once_with(user_id)
|
||||
mock_garmin_client_service_instance.update_credentials.assert_called_once_with(username, password)
|
||||
mock_central_db_service_instance.get_garmin_credentials.assert_called_once_with(
|
||||
user_id
|
||||
)
|
||||
mock_garmin_client_service_instance.update_credentials.assert_called_once_with(
|
||||
username, password
|
||||
)
|
||||
mock_garmin_client_service_instance.is_authenticated.assert_called_once()
|
||||
mock_garmin_client_service_instance.authenticate.assert_called_once()
|
||||
mock_garmin_client_instance.get_activities.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_garmin_health_sync_authentication_flow(
|
||||
mock_garmin_auth_service_instance,
|
||||
mock_central_db_service_instance,
|
||||
mock_garmin_client_service_instance
|
||||
mock_garmin_client_service_instance,
|
||||
):
|
||||
user_id = 1
|
||||
username = "test@example.com"
|
||||
@@ -85,10 +99,11 @@ async def test_garmin_health_sync_authentication_flow(
|
||||
|
||||
# Mock GarminCredentials from CentralDB
|
||||
mock_credentials = GarminCredentials(
|
||||
garmin_username=username,
|
||||
garmin_password_plaintext=password
|
||||
garmin_username=username, garmin_password_plaintext=password
|
||||
)
|
||||
mock_central_db_service_instance.get_garmin_credentials.return_value = (
|
||||
mock_credentials
|
||||
)
|
||||
mock_central_db_service_instance.get_garmin_credentials.return_value = mock_credentials
|
||||
|
||||
# Mock GarminClientService authentication
|
||||
mock_garmin_client_service_instance.is_authenticated.return_value = False
|
||||
@@ -96,21 +111,29 @@ async def test_garmin_health_sync_authentication_flow(
|
||||
|
||||
# Mock GarminClientService.get_client().get_daily_summary
|
||||
mock_garmin_client_instance = AsyncMock()
|
||||
mock_garmin_client_service_instance.get_client.return_value = mock_garmin_client_instance
|
||||
mock_garmin_client_instance.get_daily_summary.return_value = [] # Simulate no summaries
|
||||
mock_garmin_client_service_instance.get_client.return_value = (
|
||||
mock_garmin_client_instance
|
||||
)
|
||||
mock_garmin_client_instance.get_daily_summary.return_value = (
|
||||
[]
|
||||
) # Simulate no summaries
|
||||
|
||||
health_service = GarminHealthService(
|
||||
garmin_client_service=mock_garmin_client_service_instance,
|
||||
central_db_service=mock_central_db_service_instance,
|
||||
garmin_auth_service=mock_garmin_auth_service_instance # Still needed for init, but methods not called
|
||||
garmin_auth_service=mock_garmin_auth_service_instance, # Still needed for init, but methods not called
|
||||
)
|
||||
|
||||
# Call sync_health_metrics_in_background, which will trigger authentication
|
||||
await health_service.sync_health_metrics_in_background(job_id="test_job")
|
||||
await health_service.sync_health_metrics_in_background(user_id=user_id)
|
||||
|
||||
# Assertions
|
||||
mock_central_db_service_instance.get_garmin_credentials.assert_called_once_with(user_id)
|
||||
mock_garmin_client_service_instance.update_credentials.assert_called_once_with(username, password)
|
||||
mock_central_db_service_instance.get_garmin_credentials.assert_called_once_with(
|
||||
user_id
|
||||
)
|
||||
mock_garmin_client_service_instance.update_credentials.assert_called_once_with(
|
||||
username, password
|
||||
)
|
||||
mock_garmin_client_service_instance.is_authenticated.assert_called_once()
|
||||
mock_garmin_client_service_instance.authenticate.assert_called_once()
|
||||
mock_garmin_client_instance.get_daily_summary.assert_called_once()
|
||||
|
||||
@@ -10,7 +10,7 @@ from src.services.auth_service import AuthService
|
||||
@pytest.fixture
|
||||
def auth_service():
|
||||
"""Fixture for AuthService with mocked CentralDBService."""
|
||||
with patch('src.services.auth_service.CentralDBService') as MockCentralDBService:
|
||||
with patch("src.services.auth_service.CentralDBService") as MockCentralDBService:
|
||||
mock_central_db_instance = MockCentralDBService.return_value
|
||||
mock_central_db_instance.get_user_by_email = AsyncMock()
|
||||
mock_central_db_instance.create_user = AsyncMock()
|
||||
@@ -21,21 +21,24 @@ def auth_service():
|
||||
service.central_db = mock_central_db_instance
|
||||
yield service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_garth_login():
|
||||
"""Fixture to mock garth.login."""
|
||||
with patch('garth.login') as mock_login:
|
||||
with patch("garth.login") as mock_login:
|
||||
yield mock_login
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_garth_client():
|
||||
"""Fixture to mock garth.client attributes."""
|
||||
with patch('garth.client') as mock_client:
|
||||
with patch("garth.client") as mock_client:
|
||||
mock_client.oauth2_token = "mock_oauth2_token"
|
||||
mock_client.refresh_token = "mock_refresh_token"
|
||||
mock_client.token_expires_at = 1234567890
|
||||
yield mock_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_garmin_connect_new_user_success(
|
||||
auth_service, mock_garth_login, mock_garth_client
|
||||
@@ -57,7 +60,11 @@ async def test_authenticate_garmin_connect_new_user_success(
|
||||
auth_service.central_db.create_token.assert_called_once()
|
||||
auth_service.central_db.update_token.assert_not_called()
|
||||
|
||||
assert result == {"message": "Garmin Connect authentication successful", "user_id": str(mock_user.id)}
|
||||
assert result == {
|
||||
"message": "Garmin Connect authentication successful",
|
||||
"user_id": str(mock_user.id),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_garmin_connect_existing_user_success(
|
||||
@@ -79,7 +86,11 @@ async def test_authenticate_garmin_connect_existing_user_success(
|
||||
auth_service.central_db.create_token.assert_called_once()
|
||||
auth_service.central_db.update_token.assert_not_called()
|
||||
|
||||
assert result == {"message": "Garmin Connect authentication successful", "user_id": str(mock_user.id)}
|
||||
assert result == {
|
||||
"message": "Garmin Connect authentication successful",
|
||||
"user_id": str(mock_user.id),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_garmin_connect_existing_user_existing_token_success(
|
||||
@@ -89,9 +100,12 @@ async def test_authenticate_garmin_connect_existing_user_existing_token_success(
|
||||
email = "existing_user_token@example.com"
|
||||
password = "password123"
|
||||
mock_user = User(id=uuid.uuid4(), name=email, email=email)
|
||||
mock_user_id = mock_user.id # Capture the generated UUID
|
||||
mock_user_id = mock_user.id # Capture the generated UUID
|
||||
mock_existing_token = TokenCreate(
|
||||
access_token="old_access", refresh_token="old_refresh", expires_at=1111111111, user_id=mock_user_id
|
||||
access_token="old_access",
|
||||
refresh_token="old_refresh",
|
||||
expires_at=1111111111,
|
||||
user_id=mock_user_id,
|
||||
)
|
||||
|
||||
auth_service.central_db.get_user_by_email.return_value = mock_user
|
||||
@@ -106,9 +120,16 @@ async def test_authenticate_garmin_connect_existing_user_existing_token_success(
|
||||
auth_service.central_db.update_token.assert_called_once()
|
||||
auth_service.central_db.create_token.assert_not_called()
|
||||
|
||||
assert result == {"message": "Garmin Connect authentication successful", "user_id": str(mock_user.id)}
|
||||
assert result == {
|
||||
"message": "Garmin Connect authentication successful",
|
||||
"user_id": str(mock_user.id),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_garmin_connect_garmin_failure(auth_service, mock_garth_login):
|
||||
async def test_authenticate_garmin_connect_garmin_failure(
|
||||
auth_service, mock_garth_login
|
||||
):
|
||||
"""Test Garmin authentication failure."""
|
||||
email = "fail_garmin@example.com"
|
||||
password = "password123"
|
||||
@@ -125,6 +146,7 @@ async def test_authenticate_garmin_connect_garmin_failure(auth_service, mock_gar
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_garmin_connect_central_db_user_creation_failure(
|
||||
auth_service, mock_garth_login, mock_garth_client
|
||||
|
||||
@@ -2,25 +2,33 @@ from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from backend.src.schemas import GarminCredentials
|
||||
from backend.src.services.garmin_auth_service import GarminAuthService
|
||||
|
||||
from src.schemas import GarminCredentials
|
||||
from src.services.garmin_auth_service import GarminAuthService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def garmin_auth_service():
|
||||
return GarminAuthService()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initial_login_success(garmin_auth_service):
|
||||
username = "test@example.com"
|
||||
password = "password123"
|
||||
|
||||
with patch('backend.src.services.garmin_auth_service.garth') as mock_garth:
|
||||
with patch("src.services.garmin_auth_service.garth") as mock_garth:
|
||||
mock_garth.Client.return_value = AsyncMock()
|
||||
mock_garth.Client.return_value.login.return_value = None # garth.login doesn't return anything directly
|
||||
mock_garth.Client.return_value.login.return_value = (
|
||||
None # garth.login doesn't return anything directly
|
||||
)
|
||||
# Mock the attributes that would be set on the client after login
|
||||
mock_garth.Client.return_value.access_token = f"mock_access_token_for_{username}"
|
||||
mock_garth.Client.return_value.access_token_secret = f"mock_access_token_secret_for_{username}"
|
||||
mock_garth.Client.return_value.access_token = (
|
||||
f"mock_access_token_for_{username}"
|
||||
)
|
||||
mock_garth.Client.return_value.access_token_secret = (
|
||||
f"mock_access_token_secret_for_{username}"
|
||||
)
|
||||
mock_garth.Client.return_value.expires_in = 300
|
||||
|
||||
credentials = await garmin_auth_service.initial_login(username, password)
|
||||
@@ -33,19 +41,23 @@ async def test_initial_login_success(garmin_auth_service):
|
||||
assert isinstance(credentials.token_expiration_date, datetime)
|
||||
assert credentials.token_expiration_date > datetime.utcnow()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initial_login_failure(garmin_auth_service):
|
||||
username = "invalid@example.com"
|
||||
password = "wrongpassword"
|
||||
|
||||
with patch('backend.src.services.garmin_auth_service.garth') as mock_garth:
|
||||
with patch("backend.src.services.garmin_auth_service.garth") as mock_garth:
|
||||
mock_garth.Client.return_value = AsyncMock()
|
||||
mock_garth.Client.return_value.login.side_effect = Exception("Garmin login failed")
|
||||
mock_garth.Client.return_value.login.side_effect = Exception(
|
||||
"Garmin login failed"
|
||||
)
|
||||
|
||||
credentials = await garmin_auth_service.initial_login(username, password)
|
||||
|
||||
assert credentials is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_tokens_success(garmin_auth_service):
|
||||
credentials = GarminCredentials(
|
||||
@@ -53,14 +65,16 @@ async def test_refresh_tokens_success(garmin_auth_service):
|
||||
garmin_password_plaintext="password123",
|
||||
access_token="old_access_token",
|
||||
access_token_secret="old_access_token_secret",
|
||||
token_expiration_date=datetime.utcnow() - timedelta(minutes=1) # Expired token
|
||||
token_expiration_date=datetime.utcnow() - timedelta(minutes=1), # Expired token
|
||||
)
|
||||
|
||||
with patch('backend.src.services.garmin_auth_service.garth') as mock_garth:
|
||||
with patch("backend.src.services.garmin_auth_service.garth") as mock_garth:
|
||||
mock_garth.Client.return_value = AsyncMock()
|
||||
mock_garth.Client.return_value.reauthorize.return_value = None
|
||||
mock_garth.Client.return_value.access_token = "refreshed_access_token"
|
||||
mock_garth.Client.return_value.access_token_secret = "refreshed_access_token_secret"
|
||||
mock_garth.Client.return_value.access_token_secret = (
|
||||
"refreshed_access_token_secret"
|
||||
)
|
||||
mock_garth.Client.return_value.expires_in = 300
|
||||
|
||||
refreshed_credentials = await garmin_auth_service.refresh_tokens(credentials)
|
||||
@@ -68,10 +82,13 @@ async def test_refresh_tokens_success(garmin_auth_service):
|
||||
assert refreshed_credentials is not None
|
||||
assert refreshed_credentials.garmin_username == credentials.garmin_username
|
||||
assert refreshed_credentials.access_token == "refreshed_access_token"
|
||||
assert refreshed_credentials.access_token_secret == "refreshed_access_token_secret"
|
||||
assert (
|
||||
refreshed_credentials.access_token_secret == "refreshed_access_token_secret"
|
||||
)
|
||||
assert isinstance(refreshed_credentials.token_expiration_date, datetime)
|
||||
assert refreshed_credentials.token_expiration_date > datetime.utcnow()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_tokens_failure(garmin_auth_service):
|
||||
credentials = GarminCredentials(
|
||||
@@ -79,12 +96,14 @@ async def test_refresh_tokens_failure(garmin_auth_service):
|
||||
garmin_password_plaintext="invalid_password",
|
||||
access_token="old_access_token",
|
||||
access_token_secret="old_access_token_secret",
|
||||
token_expiration_date=datetime.utcnow() - timedelta(minutes=1)
|
||||
token_expiration_date=datetime.utcnow() - timedelta(minutes=1),
|
||||
)
|
||||
|
||||
with patch('backend.src.services.garmin_auth_service.garth') as mock_garth:
|
||||
with patch("backend.src.services.garmin_auth_service.garth") as mock_garth:
|
||||
mock_garth.Client.return_value = AsyncMock()
|
||||
mock_garth.Client.return_value.reauthorize.side_effect = Exception("Garmin reauthorize failed")
|
||||
mock_garth.Client.return_value.reauthorize.side_effect = Exception(
|
||||
"Garmin reauthorize failed"
|
||||
)
|
||||
|
||||
refreshed_credentials = await garmin_auth_service.refresh_tokens(credentials)
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ async def test_rate_limiter_allows_requests_within_limit():
|
||||
except HTTPException:
|
||||
pytest.fail("HTTPException raised unexpectedly.")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limiter_raises_exception_when_exceeded():
|
||||
"""Test that the rate limiter raises an HTTPException when the rate limit is exceeded."""
|
||||
@@ -25,15 +26,20 @@ async def test_rate_limiter_raises_exception_when_exceeded():
|
||||
mock_request = MagicMock()
|
||||
|
||||
# Mock the limiter.test method
|
||||
with patch.object(rate_limiter.limiter, 'test') as mock_limiter_test:
|
||||
mock_limiter_test.side_effect = [True, False] # First call returns True, second returns False
|
||||
with patch.object(rate_limiter.limiter, "test") as mock_limiter_test:
|
||||
mock_limiter_test.side_effect = [
|
||||
True,
|
||||
False,
|
||||
] # First call returns True, second returns False
|
||||
|
||||
await rate_limiter(mock_request) # First call, should pass
|
||||
await rate_limiter(mock_request) # First call, should pass
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await rate_limiter(mock_request) # Second call, should fail
|
||||
await rate_limiter(mock_request) # Second call, should fail
|
||||
|
||||
assert exc_info.value.status_code == 429
|
||||
mock_limiter_test.assert_called_with(rate_limiter.rate_limit_item, "single_user_system")
|
||||
mock_limiter_test.assert_called_with(
|
||||
rate_limiter.rate_limit_item, "single_user_system"
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 429
|
||||
|
||||
27
backend/tests/unit/test_sync_job.py
Normal file
27
backend/tests/unit/test_sync_job.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime
|
||||
|
||||
from backend.src.models.sync_job import SyncJob
|
||||
|
||||
|
||||
def test_sync_job_defaults():
|
||||
job = SyncJob()
|
||||
assert job.status == "pending"
|
||||
assert job.progress == 0.0
|
||||
assert job.start_time is None
|
||||
assert job.end_time is None
|
||||
assert job.error_message is None
|
||||
assert job.job_type is None
|
||||
|
||||
|
||||
def test_sync_job_with_values():
|
||||
start_time = datetime.now()
|
||||
job = SyncJob(
|
||||
status="in_progress",
|
||||
progress=0.5,
|
||||
start_time=start_time,
|
||||
job_type="activities",
|
||||
)
|
||||
assert job.status == "in_progress"
|
||||
assert job.progress == 0.5
|
||||
assert job.start_time == start_time
|
||||
assert job.job_type == "activities"
|
||||
46
backend/tests/unit/test_sync_manager.py
Normal file
46
backend/tests/unit/test_sync_manager.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import pytest
|
||||
from backend.src.services.sync_manager import CurrentSyncJobManager
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_singleton():
|
||||
manager1 = CurrentSyncJobManager()
|
||||
manager2 = CurrentSyncJobManager()
|
||||
assert manager1 is manager2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_sync():
|
||||
manager = CurrentSyncJobManager()
|
||||
await manager.start_sync("activities")
|
||||
status = await manager.get_current_sync_status()
|
||||
assert status.status == "in_progress"
|
||||
assert status.job_type == "activities"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_sync_while_active():
|
||||
manager = CurrentSyncJobManager()
|
||||
await manager.start_sync("activities")
|
||||
with pytest.raises(RuntimeError):
|
||||
await manager.start_sync("workouts")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_sync():
|
||||
manager = CurrentSyncJobManager()
|
||||
await manager.start_sync("activities")
|
||||
await manager.complete_sync()
|
||||
status = await manager.get_current_sync_status()
|
||||
assert status.status == "completed"
|
||||
assert status.progress == 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fail_sync():
|
||||
manager = CurrentSyncJobManager()
|
||||
await manager.start_sync("activities")
|
||||
await manager.fail_sync("Test error")
|
||||
status = await manager.get_current_sync_status()
|
||||
assert status.status == "failed"
|
||||
assert status.error_message == "Test error"
|
||||
@@ -1,36 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.jobs import JobStore, SyncJob
|
||||
from src.services.sync_status_service import SyncStatusService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_job_store():
|
||||
"""Fixture to create a mock JobStore."""
|
||||
job_store = MagicMock(spec=JobStore)
|
||||
job_id = uuid.uuid4()
|
||||
job = SyncJob(id=str(job_id), status="completed", created_at=datetime.utcnow())
|
||||
job_store.get_all_jobs.return_value = [job]
|
||||
job_store.get_job.return_value = job
|
||||
return job_store
|
||||
|
||||
def test_get_sync_jobs_all(mock_job_store):
|
||||
"""Test retrieving all sync jobs."""
|
||||
service = SyncStatusService(job_store=mock_job_store)
|
||||
jobs = service.get_sync_jobs()
|
||||
assert len(jobs) == 1
|
||||
mock_job_store.get_all_jobs.assert_called_once()
|
||||
|
||||
def test_get_sync_job_by_id(mock_job_store):
|
||||
"""Test retrieving a single sync job by ID."""
|
||||
service = SyncStatusService(job_store=mock_job_store)
|
||||
job_id = mock_job_store.get_job.return_value.id
|
||||
# The get_sync_jobs implementation filters all jobs, so we need to mock get_all_jobs
|
||||
mock_job_store.get_all_jobs.return_value = [mock_job_store.get_job.return_value]
|
||||
jobs = service.get_sync_jobs(job_id=job_id)
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0].id == str(job_id)
|
||||
Reference in New Issue
Block a user