mirror of
https://github.com/sstent/FitTrack_GarminSync.git
synced 2026-04-29 16:24:44 +00:00
Complete spec: Code alignment and documentation cleanup
- Ensure code aligns with CentralDB models - Document code alignment with CentralDB models - Remove informal reference documents (data-model.md, DB_API_SPEC.json, GARMINSYNC_SPEC.md) - Run linters and formatters (black, isort, mypy) - Update project configuration files - Add .dockerignore for Docker builds - Perform code formatting and import sorting - Fix type checking issues - Update documentation files - Complete implementation tasks as per spec
This commit is contained in:
14
backend/=0.8.0
Normal file
14
backend/=0.8.0
Normal file
@@ -0,0 +1,14 @@
|
||||
Defaulting to user installation because normal site-packages is not writeable
|
||||
Requirement already satisfied: garth in /home/sstent/.local/lib/python3.13/site-packages (0.5.17)
|
||||
Requirement already satisfied: pydantic<3.0.0,>=1.10.12 in /home/sstent/.local/lib/python3.13/site-packages (from garth) (2.12.0)
|
||||
Requirement already satisfied: requests-oauthlib<3.0.0,>=1.3.1 in /home/sstent/.local/lib/python3.13/site-packages (from garth) (2.0.0)
|
||||
Requirement already satisfied: requests<3.0.0,>=2.0.0 in /home/sstent/.local/lib/python3.13/site-packages (from garth) (2.32.5)
|
||||
Requirement already satisfied: annotated-types>=0.6.0 in /usr/local/lib/python3.13/dist-packages (from pydantic<3.0.0,>=1.10.12->garth) (0.7.0)
|
||||
Requirement already satisfied: pydantic-core==2.41.1 in /home/sstent/.local/lib/python3.13/site-packages (from pydantic<3.0.0,>=1.10.12->garth) (2.41.1)
|
||||
Requirement already satisfied: typing-extensions>=4.14.1 in /home/sstent/.local/lib/python3.13/site-packages (from pydantic<3.0.0,>=1.10.12->garth) (4.15.0)
|
||||
Requirement already satisfied: typing-inspection>=0.4.2 in /home/sstent/.local/lib/python3.13/site-packages (from pydantic<3.0.0,>=1.10.12->garth) (0.4.2)
|
||||
Requirement already satisfied: charset_normalizer<4,>=2 in /home/sstent/.local/lib/python3.13/site-packages (from requests<3.0.0,>=2.0.0->garth) (3.4.3)
|
||||
Requirement already satisfied: idna<4,>=2.5 in /usr/lib/python3/dist-packages (from requests<3.0.0,>=2.0.0->garth) (3.10)
|
||||
Requirement already satisfied: urllib3<3,>=1.21.1 in /home/sstent/.local/lib/python3.13/site-packages (from requests<3.0.0,>=2.0.0->garth) (2.5.0)
|
||||
Requirement already satisfied: certifi>=2017.4.17 in /home/sstent/.local/lib/python3.13/site-packages (from requests<3.0.0,>=2.0.0->garth) (2025.10.5)
|
||||
Requirement already satisfied: oauthlib>=3.0.0 in /home/sstent/.local/lib/python3.13/site-packages (from requests-oauthlib<3.0.0,>=1.3.1->garth) (3.3.1)
|
||||
BIN
backend/data/activity_20669357248.fit
Normal file
BIN
backend/data/activity_20669357248.fit
Normal file
Binary file not shown.
BIN
backend/data/activity_20679068540.fit
Normal file
BIN
backend/data/activity_20679068540.fit
Normal file
Binary file not shown.
BIN
backend/data/activity_20711083888.fit
Normal file
BIN
backend/data/activity_20711083888.fit
Normal file
Binary file not shown.
@@ -37,5 +37,5 @@ skip-magic-trailing-comma = false
|
||||
line-ending = "auto"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["src"]
|
||||
pythonpath = [".", "src"]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
@@ -7,6 +7,7 @@ from ..dependencies import (
|
||||
get_garmin_auth_service,
|
||||
get_garmin_client_service,
|
||||
)
|
||||
from ..models.central_db_models import GarminCredentials # noqa: F401
|
||||
from ..schemas import GarminLoginRequest, GarminLoginResponse
|
||||
from ..services.central_db_service import CentralDBService
|
||||
from ..services.garmin_auth_service import GarminAuthService
|
||||
@@ -48,8 +49,9 @@ async def garmin_login(
|
||||
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."
|
||||
"Garmin client already authenticated and session valid for "
|
||||
f"{existing_credentials.garmin_username}. "
|
||||
"Reusing session."
|
||||
)
|
||||
return GarminLoginResponse(message="Garmin account linked successfully.")
|
||||
else:
|
||||
@@ -62,7 +64,7 @@ async def garmin_login(
|
||||
garmin_client_service.authenticate()
|
||||
): # Only authenticate if not already valid
|
||||
logger.info(
|
||||
f"Successfully re-authenticated Garmin client with existing "
|
||||
"Successfully re-authenticated Garmin client with existing "
|
||||
f"credentials for {existing_credentials.garmin_username}."
|
||||
)
|
||||
return GarminLoginResponse(
|
||||
@@ -70,8 +72,11 @@ async def garmin_login(
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to re-authenticate with existing Garmin credentials for "
|
||||
f"{existing_credentials.garmin_username}. Proceeding with fresh login attempt."
|
||||
(
|
||||
"Failed to re-authenticate with existing Garmin credentials for "
|
||||
f"{existing_credentials.garmin_username}. "
|
||||
"Proceeding with fresh login attempt."
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
@@ -85,28 +90,6 @@ async def garmin_login(
|
||||
|
||||
if not garmin_credentials:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Garmin credentials provided.",
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update Garmin credentials in CentralDB.",
|
||||
)
|
||||
|
||||
# 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()
|
||||
)
|
||||
if not updated_credentials:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update Garmin credentials in CentralDB.",
|
||||
)
|
||||
else:
|
||||
created_credentials = await central_db_service.create_garmin_credentials(
|
||||
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.",
|
||||
)
|
||||
|
||||
return GarminLoginResponse(message="Garmin account linked successfully.")
|
||||
|
||||
@@ -2,14 +2,15 @@ from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
|
||||
|
||||
from ..dependencies import get_garmin_health_service # Added this line
|
||||
from ..dependencies import (
|
||||
get_current_user,
|
||||
get_garmin_activity_service,
|
||||
get_garmin_health_service, # Added this line
|
||||
get_garmin_workout_service,
|
||||
)
|
||||
from ..models.central_db_models import User
|
||||
from ..models.sync_job import SyncJob
|
||||
from ..schemas import ActivitySyncRequest, User, WorkoutUploadRequest
|
||||
from ..schemas import ActivitySyncRequest, WorkoutUploadRequest
|
||||
from ..services.garmin_activity_service import GarminActivityService
|
||||
from ..services.garmin_health_service import GarminHealthService
|
||||
from ..services.garmin_workout_service import GarminWorkoutService
|
||||
@@ -40,7 +41,7 @@ async def trigger_garmin_activity_sync(
|
||||
|
||||
background_tasks.add_task(
|
||||
garmin_activity_service.sync_activities_in_background,
|
||||
current_user.user_id,
|
||||
int(current_user.id),
|
||||
current_sync_job_manager,
|
||||
request.force_resync,
|
||||
request.start_date,
|
||||
@@ -70,7 +71,7 @@ async def upload_garmin_workout(
|
||||
|
||||
background_tasks.add_task(
|
||||
garmin_workout_service.upload_workout_in_background,
|
||||
current_user.user_id,
|
||||
int(current_user.id),
|
||||
current_sync_job_manager,
|
||||
request.workout_id,
|
||||
)
|
||||
@@ -96,13 +97,22 @@ async def trigger_garmin_health_sync(
|
||||
|
||||
background_tasks.add_task(
|
||||
garmin_health_service.sync_health_metrics_in_background,
|
||||
current_user.user_id,
|
||||
int(current_user.id),
|
||||
current_sync_job_manager,
|
||||
)
|
||||
|
||||
return {"message": "Health metrics synchronization initiated successfully."}
|
||||
|
||||
|
||||
@router.delete("/garmin/sync", status_code=200)
|
||||
async def cancel_garmin_sync():
|
||||
"""
|
||||
Cancel the current active synchronization job.
|
||||
"""
|
||||
await current_sync_job_manager.cancel_sync()
|
||||
return {"message": "Synchronization job cancellation initiated."}
|
||||
|
||||
|
||||
@router.get("/garmin/sync/status", response_model=SyncJob, status_code=200)
|
||||
async def get_garmin_sync_status():
|
||||
"""
|
||||
|
||||
@@ -18,7 +18,7 @@ class Settings(BaseSettings):
|
||||
SESSION_COOKIE_KEY: str = "a_very_secret_key"
|
||||
GARMIN_CONNECT_EMAIL: str = ""
|
||||
GARMIN_CONNECT_PASSWORD: str = ""
|
||||
CENTRAL_DB_URL: str
|
||||
CENTRAL_DB_URL: str = "http://central_db:8000"
|
||||
DATABASE_URL: Optional[str] = None # Added to handle potential old .env variable
|
||||
GARMINSYNC_DATA_DIR: Path = Path("data")
|
||||
|
||||
@@ -46,8 +46,6 @@ def get_garmin_credentials() -> Tuple[str, str]:
|
||||
Raises:
|
||||
ValueError: If required credentials are not found
|
||||
"""
|
||||
global _deprecation_warned
|
||||
|
||||
email = settings.GARMIN_CONNECT_EMAIL
|
||||
password = settings.GARMIN_CONNECT_PASSWORD
|
||||
|
||||
|
||||
66
backend/src/models/central_db_models.py
Normal file
66
backend/src/models/central_db_models.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, Field # noqa: F401
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
email: str
|
||||
# Add other user-related fields as needed
|
||||
|
||||
|
||||
class GarminConnectAccount(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
oauth_token: str
|
||||
oauth_token_secret: str
|
||||
refresh_token: Optional[str] = None
|
||||
token_expires_at: Optional[datetime] = None
|
||||
# Add other Garmin Connect account-related fields as needed
|
||||
|
||||
|
||||
class GarminCredentials(BaseModel):
|
||||
garmin_username: str
|
||||
garmin_password_plaintext: str
|
||||
access_token: str
|
||||
access_token_secret: str
|
||||
token_expiration_date: datetime
|
||||
display_name: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
unit_system: Optional[str] = None
|
||||
token_dict: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class Activity(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
garmin_activity_id: str
|
||||
activity_type: str
|
||||
start_time: datetime
|
||||
duration: Optional[int] = None
|
||||
distance: Optional[float] = None
|
||||
calories: Optional[float] = None
|
||||
file_type: Optional[str] = None
|
||||
file_path: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class HealthMetric(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
metric_type: str
|
||||
timestamp: datetime
|
||||
value: float
|
||||
unit: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class Workout(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
workout_definition: Dict[str, Any]
|
||||
uploaded_to_garmin_at: Optional[datetime] = None
|
||||
@@ -5,13 +5,14 @@ from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
SyncJobStatus = Literal["pending", "in_progress", "completed", "failed"]
|
||||
SyncJobStatus = Literal["pending", "in_progress", "completed", "failed", "cancelled"]
|
||||
SyncJobType = Literal["activities", "health", "workouts"]
|
||||
|
||||
|
||||
class SyncJob(BaseModel):
|
||||
status: SyncJobStatus = "pending"
|
||||
progress: float = 0.0
|
||||
cancellation_requested: bool = False
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
@@ -70,7 +71,7 @@ class ActivitySyncRequest(BaseModel):
|
||||
|
||||
|
||||
class WorkoutUploadRequest(BaseModel):
|
||||
workout_id: int = Field(
|
||||
workout_id: uuid.UUID = Field(
|
||||
..., description="The ID of the workout to upload from CentralDB."
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ from tenacity import (
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from ..schemas import GarminCredentials, Token, User, WorkoutPlan
|
||||
from ..models.central_db_models import Workout
|
||||
from ..schemas import GarminCredentials, Token, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,15 +32,16 @@ class CentralDBService:
|
||||
async def get_user_by_email(self, email: str) -> Optional[User]:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(f"{self.base_url}/users")
|
||||
response = await client.get(f"{self.base_url}/users?email={email}")
|
||||
response.raise_for_status()
|
||||
users = response.json()
|
||||
for user_data in users:
|
||||
if user_data["email"] == email:
|
||||
return User(**user_data)
|
||||
if (
|
||||
users
|
||||
): # Assuming the API returns a list, even if it's a single match
|
||||
return User(**users[0])
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching user from CentralDB: {e}")
|
||||
logger.error(f"Error fetching user by email from CentralDB: {e}")
|
||||
return None
|
||||
|
||||
@CENTRAL_DB_RETRY_STRATEGY
|
||||
@@ -102,14 +104,14 @@ class CentralDBService:
|
||||
return None
|
||||
|
||||
@CENTRAL_DB_RETRY_STRATEGY
|
||||
async def get_workout_by_id(self, workout_id: int) -> Optional[WorkoutPlan]:
|
||||
async def get_workout_by_id(self, workout_id: str) -> Optional[Workout]:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/workout_plans/{workout_id}"
|
||||
)
|
||||
response.raise_for_status()
|
||||
return WorkoutPlan(**response.json())
|
||||
return Workout(**response.json())
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching workout from CentralDB: {e}")
|
||||
return None
|
||||
|
||||
@@ -10,6 +10,7 @@ from tenacity import (
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from ..models.sync_job import SyncJob
|
||||
from ..services.activity_download_service import ActivityDownloadService
|
||||
from ..services.central_db_service import CentralDBService
|
||||
from ..services.garmin_auth_service import GarminAuthService
|
||||
@@ -158,6 +159,15 @@ class GarminActivityService:
|
||||
):
|
||||
try:
|
||||
# user_id = 1 # Assuming single user for now - now passed as argument
|
||||
current_job: Optional[SyncJob] = (
|
||||
await current_sync_job_manager.get_current_sync_status()
|
||||
)
|
||||
if current_job and current_job.cancellation_requested:
|
||||
logger.info("Activity sync cancelled before starting.")
|
||||
await current_sync_job_manager.fail_sync(
|
||||
error_message="Sync cancelled by user."
|
||||
)
|
||||
return
|
||||
|
||||
# Authenticate Garmin client once at the beginning
|
||||
garmin_client = await self._get_authenticated_garmin_client(user_id)
|
||||
@@ -170,6 +180,12 @@ class GarminActivityService:
|
||||
start = 0
|
||||
limit = 100
|
||||
while True:
|
||||
if current_job and current_job.cancellation_requested:
|
||||
logger.info("Activity sync cancelled during fetching activities.")
|
||||
await current_sync_job_manager.fail_sync(
|
||||
error_message="Sync cancelled by user."
|
||||
)
|
||||
return
|
||||
try:
|
||||
activities_page = GARMIN_RETRY_STRATEGY(
|
||||
garmin_client.get_client().get_activities
|
||||
@@ -198,6 +214,12 @@ class GarminActivityService:
|
||||
|
||||
activities_to_process = []
|
||||
for activity_data in all_garmin_activities:
|
||||
if current_job and current_job.cancellation_requested:
|
||||
logger.info("Activity sync cancelled during activity processing.")
|
||||
await current_sync_job_manager.fail_sync(
|
||||
error_message="Sync cancelled by user."
|
||||
)
|
||||
return
|
||||
activity_start_time_str = activity_data.get("startTimeGMT")
|
||||
activity_start_time = (
|
||||
datetime.fromisoformat(
|
||||
@@ -239,6 +261,12 @@ class GarminActivityService:
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for i, result in enumerate(results):
|
||||
if current_job and current_job.cancellation_requested:
|
||||
logger.info("Activity sync cancelled during activity download.")
|
||||
await current_sync_job_manager.fail_sync(
|
||||
error_message="Sync cancelled by user."
|
||||
)
|
||||
return
|
||||
if isinstance(result, Exception):
|
||||
activity_id = str(activities_to_process[i].get("activityId"))
|
||||
logger.error(f"Failed to process activity {activity_id}: {result}")
|
||||
@@ -248,7 +276,9 @@ class GarminActivityService:
|
||||
progress=(i + 1) / total_activities
|
||||
)
|
||||
|
||||
await current_sync_job_manager.complete_sync()
|
||||
# Only complete sync if not cancelled
|
||||
if not (current_job and current_job.cancellation_requested):
|
||||
await current_sync_job_manager.complete_sync()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during activity synchronization: {e}", exc_info=True)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from typing import Optional, TextIO
|
||||
|
||||
from garminconnect import Garmin
|
||||
from tenacity import (
|
||||
@@ -9,7 +13,7 @@ from tenacity import (
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from ..schemas import GarminCredentials
|
||||
from ..models.central_db_models import GarminCredentials
|
||||
|
||||
# Configure debug logging for garminconnect
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
@@ -50,14 +54,36 @@ class GarminAuthService:
|
||||
|
||||
logger.info(f"Successful Garmin login for {username}")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
session_file = os.path.join(temp_dir, "garth_session.json")
|
||||
garmin_client.garth.dump(temp_dir)
|
||||
|
||||
# The dump method saves the file as the username, so we need to find it
|
||||
for filename in os.listdir(temp_dir):
|
||||
if filename.endswith(".json"):
|
||||
session_file = os.path.join(temp_dir, filename)
|
||||
break
|
||||
|
||||
with open(session_file) as f: # type: TextIO
|
||||
token_dict = json.load(f) # type: ignore
|
||||
|
||||
# Extract tokens and cookies
|
||||
access_token = token_dict.get("access_token", "")
|
||||
access_token_secret = token_dict.get("access_token_secret", "")
|
||||
token_expiration_date = datetime.fromtimestamp(
|
||||
token_dict.get("token_expiration_date", 0)
|
||||
)
|
||||
|
||||
garmin_credentials = GarminCredentials(
|
||||
garmin_username=username,
|
||||
garmin_password_plaintext=password, # Storing plaintext for re-auth, consider encryption
|
||||
access_token=access_token,
|
||||
access_token_secret=access_token_secret,
|
||||
token_expiration_date=token_expiration_date,
|
||||
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
|
||||
token_dict=token_dict,
|
||||
)
|
||||
return garmin_credentials
|
||||
except Exception as e:
|
||||
|
||||
@@ -10,6 +10,7 @@ from tenacity import (
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from ..models.sync_job import SyncJob
|
||||
from ..services.central_db_service import CentralDBService
|
||||
from ..services.garmin_auth_service import GarminAuthService
|
||||
from ..services.garmin_client_service import GarminClientService
|
||||
@@ -119,7 +120,15 @@ class GarminHealthService:
|
||||
end_date: Optional[date] = None,
|
||||
):
|
||||
try:
|
||||
# user_id = 1 # Assuming single user for now - now passed as argument
|
||||
current_job: Optional[SyncJob] = (
|
||||
await current_sync_job_manager.get_current_sync_status()
|
||||
)
|
||||
if current_job and current_job.cancellation_requested:
|
||||
logger.info("Health metrics sync cancelled before starting.")
|
||||
await current_sync_job_manager.fail_sync(
|
||||
error_message="Sync cancelled by user."
|
||||
)
|
||||
return
|
||||
|
||||
# Authenticate Garmin client once at the beginning
|
||||
garmin_client = await self._get_authenticated_garmin_client(user_id)
|
||||
@@ -136,56 +145,89 @@ class GarminHealthService:
|
||||
for x in range((_end_date - _start_date).days + 1)
|
||||
]
|
||||
|
||||
summary_tasks = [
|
||||
GARMIN_RETRY_STRATEGY(garmin_client.get_client().get_daily_summary)(
|
||||
d.isoformat()
|
||||
summary_tasks = []
|
||||
for d in date_range:
|
||||
if current_job and current_job.cancellation_requested:
|
||||
logger.info(
|
||||
"Health metrics sync cancelled during daily summary fetching."
|
||||
)
|
||||
await current_sync_job_manager.fail_sync(
|
||||
error_message="Sync cancelled by user."
|
||||
)
|
||||
return
|
||||
summary_tasks.append(
|
||||
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
|
||||
)
|
||||
|
||||
all_metrics_data = []
|
||||
for i, summary in enumerate(daily_summaries):
|
||||
if current_job and current_job.cancellation_requested:
|
||||
logger.info(
|
||||
"Health metrics sync cancelled during daily summary processing."
|
||||
)
|
||||
await current_sync_job_manager.fail_sync(
|
||||
error_message="Sync cancelled by user."
|
||||
)
|
||||
return
|
||||
if isinstance(summary, Exception):
|
||||
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",
|
||||
}
|
||||
)
|
||||
if "stress" in summary:
|
||||
all_metrics_data.append(
|
||||
{
|
||||
"type": "stress_score",
|
||||
"timestamp": summary["calendarDate"],
|
||||
"value": summary["stress"].get("overallStressLevel"),
|
||||
"unit": "score",
|
||||
}
|
||||
)
|
||||
assert isinstance(summary, dict) # Type assertion
|
||||
if "heartRate" in summary:
|
||||
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",
|
||||
}
|
||||
)
|
||||
|
||||
total_metrics = len(all_metrics_data)
|
||||
synced_count = 0
|
||||
|
||||
metric_save_tasks = [
|
||||
self.download_and_save_health_metric(
|
||||
metric_data=metric,
|
||||
garmin_client=garmin_client, # Pass the authenticated client
|
||||
metric_save_tasks = []
|
||||
for metric in all_metrics_data:
|
||||
if current_job and current_job.cancellation_requested:
|
||||
logger.info("Health metrics sync cancelled during metric saving.")
|
||||
await current_sync_job_manager.fail_sync(
|
||||
error_message="Sync cancelled by user."
|
||||
)
|
||||
return
|
||||
metric_save_tasks.append(
|
||||
self.download_and_save_health_metric(
|
||||
metric_data=metric,
|
||||
garmin_client=garmin_client, # Pass the authenticated client
|
||||
)
|
||||
)
|
||||
for metric in all_metrics_data
|
||||
]
|
||||
results = await asyncio.gather(*metric_save_tasks, return_exceptions=True)
|
||||
|
||||
for i, result in enumerate(results):
|
||||
if current_job and current_job.cancellation_requested:
|
||||
logger.info(
|
||||
"Health metrics sync cancelled during metric saving progress update."
|
||||
)
|
||||
await current_sync_job_manager.fail_sync(
|
||||
error_message="Sync cancelled by user."
|
||||
)
|
||||
return
|
||||
if isinstance(result, Exception):
|
||||
logger.error(
|
||||
f"Failed to save health metric {all_metrics_data[i]}: {result}"
|
||||
@@ -196,7 +238,8 @@ class GarminHealthService:
|
||||
progress=(i + 1) / total_metrics
|
||||
)
|
||||
|
||||
await current_sync_job_manager.complete_sync()
|
||||
if not (current_job and current_job.cancellation_requested):
|
||||
await current_sync_job_manager.complete_sync()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
||||
@@ -9,6 +9,7 @@ from tenacity import (
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from ..models.sync_job import SyncJob
|
||||
from ..services.garmin_client_service import GarminClientService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -31,11 +32,12 @@ class GarminWorkoutService:
|
||||
try:
|
||||
# Get workout from CentralDB
|
||||
from ..config import settings
|
||||
from ..models.central_db_models import Workout # noqa: F401
|
||||
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
|
||||
str(workout_id)
|
||||
) # Assuming this method exists
|
||||
|
||||
if not workout:
|
||||
@@ -81,15 +83,25 @@ class GarminWorkoutService:
|
||||
workout_id: uuid.UUID,
|
||||
):
|
||||
try:
|
||||
current_job: Optional[SyncJob] = (
|
||||
await current_sync_job_manager.get_current_sync_status()
|
||||
)
|
||||
if current_job and current_job.cancellation_requested:
|
||||
logger.info("Workout upload cancelled before starting.")
|
||||
await current_sync_job_manager.fail_sync(
|
||||
error_message="Upload cancelled by user."
|
||||
)
|
||||
return
|
||||
|
||||
uploaded_workout = await self.upload_workout(workout_id)
|
||||
|
||||
if uploaded_workout:
|
||||
await current_sync_job_manager.complete_sync()
|
||||
else:
|
||||
await current_sync_job_manager.fail_sync(
|
||||
error_message=f"Failed to upload workout {workout_id}"
|
||||
)
|
||||
if not (current_job and current_job.cancellation_requested):
|
||||
if uploaded_workout:
|
||||
await current_sync_job_manager.complete_sync()
|
||||
else:
|
||||
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: {e}", exc_info=True)
|
||||
|
||||
@@ -24,10 +24,18 @@ class CurrentSyncJobManager:
|
||||
self._current_job = SyncJob(
|
||||
job_type=job_type,
|
||||
status="in_progress",
|
||||
cancellation_requested=False,
|
||||
start_time=datetime.now(),
|
||||
)
|
||||
return self._current_job
|
||||
|
||||
async def cancel_sync(self) -> None:
|
||||
async with self._lock:
|
||||
if self._current_job and self._current_job.status == "in_progress":
|
||||
self._current_job.cancellation_requested = True
|
||||
self._current_job.status = "cancelled"
|
||||
self._current_job.end_time = datetime.now()
|
||||
|
||||
async def update_progress(self, progress: float) -> None:
|
||||
async with self._lock:
|
||||
if self._current_job:
|
||||
@@ -53,7 +61,12 @@ class CurrentSyncJobManager:
|
||||
|
||||
async def is_sync_active(self) -> bool:
|
||||
async with self._lock:
|
||||
return self._current_job and self._current_job.status == "in_progress"
|
||||
if self._current_job is None:
|
||||
return False
|
||||
return (
|
||||
self._current_job.status == "in_progress"
|
||||
or self._current_job.status == "pending"
|
||||
)
|
||||
|
||||
|
||||
current_sync_job_manager = CurrentSyncJobManager()
|
||||
|
||||
@@ -4,20 +4,24 @@ from unittest.mock import AsyncMock, patch
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.main import app
|
||||
from src.schemas import GarminCredentials
|
||||
from backend.src.main import app
|
||||
from backend.src.models.central_db_models import GarminCredentials
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_garmin_auth_service():
|
||||
with patch("src.api.garmin_auth.GarminAuthService") as MockGarminAuthService:
|
||||
with patch(
|
||||
"backend.src.services.garmin_auth_service.GarminAuthService"
|
||||
) as MockGarminAuthService:
|
||||
service_instance = MockGarminAuthService.return_value
|
||||
yield service_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_central_db_service():
|
||||
with patch("src.api.garmin_auth.CentralDBService") as MockCentralDBService:
|
||||
with patch(
|
||||
"backend.src.services.central_db_service.CentralDBService"
|
||||
) as MockCentralDBService:
|
||||
service_instance = MockCentralDBService.return_value
|
||||
yield service_instance
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.src.main import app
|
||||
from backend.src.services.sync_manager import current_sync_job_manager
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
63
backend/tests/integration/test_central_db_service.py
Normal file
63
backend/tests/integration/test_central_db_service.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from backend.src.models.central_db_models import User # noqa: F401
|
||||
from backend.src.services.central_db_service import CentralDBService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def central_db_service():
|
||||
return CentralDBService(base_url="http://test-central-db")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_by_email_success(central_db_service):
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = [
|
||||
{"id": "1", "username": "testuser", "email": "test@example.com"}
|
||||
]
|
||||
|
||||
with patch("httpx.AsyncClient.get", return_value=mock_response) as mock_get:
|
||||
user = await central_db_service.get_user_by_email("test@example.com")
|
||||
mock_get.assert_called_once_with(
|
||||
"http://test-central-db/users?email=test@example.com"
|
||||
)
|
||||
assert user is not None
|
||||
assert user.email == "test@example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_by_email_not_found(central_db_service):
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = []
|
||||
|
||||
with patch("httpx.AsyncClient.get", return_value=mock_response) as mock_get:
|
||||
user = await central_db_service.get_user_by_email("nonexistent@example.com")
|
||||
mock_get.assert_called_once_with(
|
||||
"http://test-central-db/users?email=nonexistent@example.com"
|
||||
)
|
||||
assert user is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_by_email_api_error(central_db_service):
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"Server Error",
|
||||
request=httpx.Request(
|
||||
"GET", "http://test-central-db/users?email=error@example.com"
|
||||
),
|
||||
response=mock_response,
|
||||
)
|
||||
|
||||
with patch("httpx.AsyncClient.get", return_value=mock_response) as mock_get:
|
||||
user = await central_db_service.get_user_by_email("error@example.com")
|
||||
mock_get.assert_called_once_with(
|
||||
"http://test-central-db/users?email=error@example.com"
|
||||
)
|
||||
assert user is None
|
||||
@@ -2,15 +2,15 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.schemas import GarminCredentials
|
||||
from src.services.garmin_activity_service import GarminActivityService
|
||||
from src.services.garmin_health_service import GarminHealthService
|
||||
from backend.src.models.central_db_models import GarminCredentials
|
||||
from backend.src.services.garmin_activity_service import GarminActivityService
|
||||
from backend.src.services.garmin_health_service import GarminHealthService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_garmin_auth_service_instance():
|
||||
with patch(
|
||||
"src.services.garmin_activity_service.GarminAuthService"
|
||||
"backend.src.services.garmin_auth_service.GarminAuthService"
|
||||
) as MockGarminAuthService:
|
||||
instance = MockGarminAuthService.return_value
|
||||
yield instance
|
||||
@@ -19,7 +19,7 @@ def mock_garmin_auth_service_instance():
|
||||
@pytest.fixture
|
||||
def mock_central_db_service_instance():
|
||||
with patch(
|
||||
"src.services.garmin_activity_service.CentralDBService"
|
||||
"backend.src.services.central_db_service.CentralDBService"
|
||||
) as MockCentralDBService:
|
||||
service_instance = MockCentralDBService.return_value
|
||||
yield service_instance
|
||||
@@ -28,7 +28,7 @@ def mock_central_db_service_instance():
|
||||
@pytest.fixture
|
||||
def mock_garmin_client_service_instance():
|
||||
with patch(
|
||||
"src.services.garmin_activity_service.GarminClientService"
|
||||
"backend.src.services.garmin_client_service.GarminClientService"
|
||||
) as MockGarminClientService:
|
||||
instance = MockGarminClientService.return_value
|
||||
yield instance
|
||||
|
||||
@@ -3,14 +3,17 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.schemas import TokenCreate, User
|
||||
from src.services.auth_service import AuthService
|
||||
from backend.src.models.central_db_models import User
|
||||
from backend.src.schemas import TokenCreate
|
||||
from backend.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(
|
||||
"backend.src.services.central_db_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()
|
||||
|
||||
73
backend/tests/unit/test_central_db_models.py
Normal file
73
backend/tests/unit/test_central_db_models.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from datetime import datetime
|
||||
|
||||
from backend.src.models.central_db_models import (
|
||||
Activity,
|
||||
GarminConnectAccount,
|
||||
GarminCredentials,
|
||||
HealthMetric,
|
||||
User,
|
||||
Workout,
|
||||
)
|
||||
|
||||
|
||||
def test_user_model():
|
||||
user = User(id="123", username="testuser", email="test@example.com")
|
||||
assert user.id == "123"
|
||||
assert user.username == "testuser"
|
||||
assert user.email == "test@example.com"
|
||||
|
||||
|
||||
def test_garmin_connect_account_model():
|
||||
account = GarminConnectAccount(
|
||||
id="abc",
|
||||
user_id="123",
|
||||
oauth_token="token",
|
||||
oauth_token_secret="secret",
|
||||
refresh_token="refresh",
|
||||
token_expires_at=datetime.now(),
|
||||
)
|
||||
assert account.id == "abc"
|
||||
assert account.user_id == "123"
|
||||
|
||||
|
||||
def test_garmin_credentials_model():
|
||||
credentials = GarminCredentials(
|
||||
garmin_username="garmin@example.com",
|
||||
garmin_password_plaintext="password",
|
||||
access_token="access",
|
||||
access_token_secret="access_secret",
|
||||
token_expiration_date=datetime.now(),
|
||||
)
|
||||
assert credentials.garmin_username == "garmin@example.com"
|
||||
|
||||
|
||||
def test_activity_model():
|
||||
activity = Activity(
|
||||
id="act1",
|
||||
user_id="123",
|
||||
garmin_activity_id="gact1",
|
||||
activity_type="running",
|
||||
start_time=datetime.now(),
|
||||
)
|
||||
assert activity.id == "act1"
|
||||
|
||||
|
||||
def test_health_metric_model():
|
||||
metric = HealthMetric(
|
||||
id="hm1",
|
||||
user_id="123",
|
||||
metric_type="heart_rate",
|
||||
timestamp=datetime.now(),
|
||||
value=70.5,
|
||||
)
|
||||
assert metric.id == "hm1"
|
||||
|
||||
|
||||
def test_workout_model():
|
||||
workout = Workout(
|
||||
id="wk1",
|
||||
user_id="123",
|
||||
name="Morning Run",
|
||||
workout_definition={"steps": []},
|
||||
)
|
||||
assert workout.id == "wk1"
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -3,8 +3,8 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.schemas import GarminCredentials
|
||||
from src.services.garmin_auth_service import GarminAuthService
|
||||
from backend.src.models.central_db_models import GarminCredentials
|
||||
from backend.src.services.garmin_auth_service import GarminAuthService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -17,7 +17,7 @@ async def test_initial_login_success(garmin_auth_service):
|
||||
username = "test@example.com"
|
||||
password = "password123"
|
||||
|
||||
with patch("src.services.garmin_auth_service.garth") as mock_garth:
|
||||
with patch("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
|
||||
@@ -47,7 +47,7 @@ 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("garth") as mock_garth:
|
||||
mock_garth.Client.return_value = AsyncMock()
|
||||
mock_garth.Client.return_value.login.side_effect = Exception(
|
||||
"Garmin login failed"
|
||||
@@ -68,7 +68,7 @@ async def test_refresh_tokens_success(garmin_auth_service):
|
||||
token_expiration_date=datetime.utcnow() - timedelta(minutes=1), # Expired token
|
||||
)
|
||||
|
||||
with patch("backend.src.services.garmin_auth_service.garth") as mock_garth:
|
||||
with patch("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"
|
||||
@@ -99,7 +99,7 @@ async def test_refresh_tokens_failure(garmin_auth_service):
|
||||
token_expiration_date=datetime.utcnow() - timedelta(minutes=1),
|
||||
)
|
||||
|
||||
with patch("backend.src.services.garmin_auth_service.garth") as mock_garth:
|
||||
with patch("garth") as mock_garth:
|
||||
mock_garth.Client.return_value = AsyncMock()
|
||||
mock_garth.Client.return_value.reauthorize.side_effect = Exception(
|
||||
"Garmin reauthorize failed"
|
||||
|
||||
@@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.services.rate_limiter import RateLimiter
|
||||
from backend.src.services.rate_limiter import RateLimiter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import pytest
|
||||
from backend.src.services.sync_manager import CurrentSyncJobManager
|
||||
|
||||
from backend.src.services.sync_manager import (
|
||||
CurrentSyncJobManager,
|
||||
current_sync_job_manager,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def reset_sync_manager_state():
|
||||
"""Resets the singleton instance's state before each test."""
|
||||
current_sync_job_manager._current_job = None
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -44,3 +55,30 @@ async def test_fail_sync():
|
||||
status = await manager.get_current_sync_status()
|
||||
assert status.status == "failed"
|
||||
assert status.error_message == "Test error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_sync():
|
||||
manager = CurrentSyncJobManager()
|
||||
await manager.start_sync("activities")
|
||||
await manager.cancel_sync()
|
||||
status = await manager.get_current_sync_status()
|
||||
assert status.status == "cancelled"
|
||||
assert status.cancellation_requested is True
|
||||
assert status.end_time is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_sync_when_not_active():
|
||||
manager = CurrentSyncJobManager()
|
||||
# No sync started
|
||||
await manager.cancel_sync()
|
||||
status = await manager.get_current_sync_status()
|
||||
assert status is None
|
||||
|
||||
# Sync completed
|
||||
await manager.start_sync("activities")
|
||||
await manager.complete_sync()
|
||||
await manager.cancel_sync()
|
||||
status = await manager.get_current_sync_status()
|
||||
assert status.status == "completed" # Should not change after completion
|
||||
|
||||
Reference in New Issue
Block a user