439 lines
22 KiB
Python
439 lines
22 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
from app.db.models import ActivityStatus, HealthState, SyncRunStatus
|
|
from app.db.repositories import ActivityRepository, SyncRunRepository, SystemLogRepository, UserRepository
|
|
from app.fit.rewriter import FitFormatError
|
|
from app.garmin.uploader import (
|
|
GarminAuthError,
|
|
GarminImportRejected,
|
|
GarminTransientError,
|
|
GarminUploadBlocked,
|
|
)
|
|
from app.mywhoosh.client import (
|
|
MyWhooshAuthError,
|
|
MyWhooshDeviceConflictError,
|
|
MyWhooshIntegrationError,
|
|
MyWhooshTransientError,
|
|
)
|
|
from app.mywhoosh.tokenstore import MyWhooshTokenStore
|
|
from app.security.credentials import CredentialCipher
|
|
from app.sync.states import SyncOutcome
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SyncAlreadyRunning(RuntimeError):
|
|
pass
|
|
|
|
|
|
# action_reason values that are resolved purely by this run's MyWhoosh listing
|
|
# succeeding (reaching the end of the activity loop with stop_user_run still
|
|
# False already proves MyWhoosh is working again -- every MyWhoosh-related
|
|
# exception branch that could fire also sets stop_user_run=True).
|
|
_MYWHOOSH_ACTION_REASONS = frozenset(
|
|
{"mywhoosh_auth_required", "mywhoosh_integration_changed", "mywhoosh_device_conflict"}
|
|
)
|
|
|
|
# action_reason values that can only be resolved by actual, this-run evidence
|
|
# of a successful Garmin import -- the mere absence of a Garmin exception does
|
|
# NOT prove anything, since no Garmin work may have been attempted this run.
|
|
_GARMIN_ACTION_REASONS = frozenset({"garmin_mfa_required", "garmin_auth_required"})
|
|
|
|
|
|
class SyncManager:
|
|
"""Resumable single-user MyWhoosh -> Garmin sync pipeline.
|
|
|
|
`_sync_user_locked` implements the state machine for one user's sync run.
|
|
`sync_user` wraps it with a per-user `asyncio.Lock` so only one sync can
|
|
run for a given user at a time (raising `SyncAlreadyRunning` on overlap),
|
|
and `sync_all_enabled` fans out across all enabled users, isolating each
|
|
user's failure to its own result rather than cancelling siblings.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
session_factory: Callable[[], Any],
|
|
credential_cipher: CredentialCipher,
|
|
settings: Any,
|
|
mywhoosh_factory: Callable[[MyWhooshTokenStore], Any],
|
|
garmin_factory: Callable[[str, str, Path], Any],
|
|
fit_converter: Callable[[Path, Path], Any],
|
|
notifier: Any = None,
|
|
) -> None:
|
|
self.session_factory = session_factory
|
|
self.credential_cipher = credential_cipher
|
|
self.settings = settings
|
|
self.mywhoosh_factory = mywhoosh_factory
|
|
self.garmin_factory = garmin_factory
|
|
self.fit_converter = fit_converter
|
|
self.notifier = notifier
|
|
self._locks: dict[int, asyncio.Lock] = {}
|
|
self._locks_guard = asyncio.Lock()
|
|
|
|
def _notify_action_required(self, session: Any, user: Any, message: str | None) -> None:
|
|
if self.notifier is None or not user.notify_email_enabled or not user.notification_email:
|
|
return
|
|
try:
|
|
self.notifier.send(
|
|
to_address=user.notification_email,
|
|
subject=f"MyWhoosh-Garmin Sync: action required for {user.name}",
|
|
body=message or "Your sync requires attention. Check the dashboard for details.",
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"sync_user: failed to send action-required notification for user %s", user.id, exc_info=True
|
|
)
|
|
try:
|
|
SystemLogRepository(session).add(
|
|
source="email_notification",
|
|
message=(
|
|
f"Failed to email {user.notification_email} for user {user.id} ({user.name}): "
|
|
f"{type(exc).__name__}: {exc}"
|
|
),
|
|
user_id=user.id,
|
|
)
|
|
except Exception:
|
|
logger.warning("sync_user: failed to record email-notification failure in system log", exc_info=True)
|
|
|
|
async def _lock_for(self, user_id: int) -> asyncio.Lock:
|
|
async with self._locks_guard:
|
|
return self._locks.setdefault(user_id, asyncio.Lock())
|
|
|
|
async def sync_user(self, user_id: int, mfa_code: str | None = None) -> SyncOutcome:
|
|
lock = await self._lock_for(user_id)
|
|
if lock.locked():
|
|
raise SyncAlreadyRunning(f"sync already running for user {user_id}")
|
|
async with lock:
|
|
return await self._sync_user_locked(user_id, mfa_code)
|
|
|
|
def _load_enabled_user_ids(self) -> list[int]:
|
|
with self.session_factory() as session:
|
|
return [user.id for user in UserRepository(session).list_enabled()]
|
|
|
|
async def sync_all_enabled(self) -> list[SyncOutcome | Exception]:
|
|
user_ids = self._load_enabled_user_ids()
|
|
return await asyncio.gather(
|
|
*(self.sync_user(user_id) for user_id in user_ids),
|
|
return_exceptions=True,
|
|
)
|
|
|
|
async def _sync_user_locked(self, user_id: int, mfa_code: str | None = None) -> SyncOutcome:
|
|
with self.session_factory() as session:
|
|
user = UserRepository(session).get(user_id)
|
|
if user is None:
|
|
raise ValueError(f"user {user_id} not found")
|
|
|
|
# Captured before this run mutates action_reason, so the
|
|
# end-of-run notification below only fires on a transition into
|
|
# (or between) action-required states -- never repeatedly for a
|
|
# cause that's already been reported and still unresolved.
|
|
previous_action_reason = user.action_reason
|
|
|
|
sync_run_repo = SyncRunRepository(session)
|
|
run = sync_run_repo.start(user_id)
|
|
|
|
# Best-available counters, tracked outside the inner try so that if
|
|
# something raises before/while they'd normally be populated, the
|
|
# outer except below can still report whatever we do know.
|
|
discovered = 0
|
|
imported_count = 0
|
|
skipped_count = 0
|
|
failed_count = 0
|
|
mywhoosh = None
|
|
|
|
try:
|
|
try:
|
|
mw_email = self.credential_cipher.decrypt(user.mywhoosh_email_enc)
|
|
mw_password = self.credential_cipher.decrypt(user.mywhoosh_password_enc)
|
|
garmin_email = self.credential_cipher.decrypt(user.garmin_email_enc)
|
|
garmin_password = self.credential_cipher.decrypt(user.garmin_password_enc)
|
|
|
|
token_dir = self.settings.tokens_dir / str(user.id)
|
|
mywhoosh = self.mywhoosh_factory(MyWhooshTokenStore(token_dir / "mywhoosh.json"))
|
|
garmin = self.garmin_factory(garmin_email, garmin_password, token_dir / "garmin")
|
|
|
|
activity_repo = ActivityRepository(session)
|
|
stop_user_run = False
|
|
summary_error: str | None = None
|
|
# True only once an actual, this-run Garmin import/duplicate
|
|
# check has succeeded for a real activity -- the sole
|
|
# evidence that can justify clearing a Garmin-class
|
|
# action_reason (see _GARMIN_ACTION_REASONS below).
|
|
garmin_succeeded_this_run = False
|
|
|
|
try:
|
|
remote_activities = await mywhoosh.list_activities(mw_email, mw_password)
|
|
except MyWhooshTransientError as exc:
|
|
user.health_state = HealthState.DEGRADED
|
|
user.mywhoosh_state = "error"
|
|
session.commit()
|
|
remote_activities = []
|
|
stop_user_run = True
|
|
summary_error = str(exc)
|
|
except MyWhooshDeviceConflictError as exc:
|
|
user.health_state = HealthState.ACTION_REQUIRED
|
|
user.mywhoosh_state = "device_conflict"
|
|
user.action_reason = "mywhoosh_device_conflict"
|
|
session.commit()
|
|
remote_activities = []
|
|
stop_user_run = True
|
|
summary_error = str(exc)
|
|
except MyWhooshAuthError as exc:
|
|
user.health_state = HealthState.ACTION_REQUIRED
|
|
user.mywhoosh_state = "auth_required"
|
|
user.action_reason = "mywhoosh_auth_required"
|
|
session.commit()
|
|
remote_activities = []
|
|
stop_user_run = True
|
|
summary_error = str(exc)
|
|
except MyWhooshIntegrationError as exc:
|
|
user.health_state = HealthState.ACTION_REQUIRED
|
|
user.mywhoosh_state = "integration_error"
|
|
user.action_reason = "mywhoosh_integration_changed"
|
|
session.commit()
|
|
remote_activities = []
|
|
stop_user_run = True
|
|
summary_error = str(exc)
|
|
except Exception as exc:
|
|
user.health_state = HealthState.DEGRADED
|
|
user.mywhoosh_state = "error"
|
|
session.commit()
|
|
remote_activities = []
|
|
stop_user_run = True
|
|
summary_error = f"{type(exc).__name__}: {str(exc)[:200]}"
|
|
logger.warning(
|
|
"sync_user: unexpected error listing activities for user %s: %s",
|
|
user.id,
|
|
exc.__class__.__name__,
|
|
)
|
|
else:
|
|
user.mywhoosh_state = "connected"
|
|
session.commit()
|
|
|
|
discovered = len(remote_activities)
|
|
|
|
for remote in remote_activities:
|
|
if stop_user_run:
|
|
break
|
|
|
|
activity, _created = activity_repo.get_or_create_discovered(
|
|
user_id=user.id,
|
|
mywhoosh_activity_id=remote.id,
|
|
activity_name=remote.title,
|
|
activity_timestamp=remote.started_at,
|
|
)
|
|
|
|
# Non-retryable failures (e.g. corrupt/unsupported FIT
|
|
# files) are terminal: never re-attempt them, and don't
|
|
# count them in any counter for this run.
|
|
if activity.status == ActivityStatus.FAILED and not activity.retryable:
|
|
continue
|
|
|
|
stage = (
|
|
activity.last_completed_stage
|
|
if activity.status == ActivityStatus.FAILED
|
|
else activity.status
|
|
)
|
|
initial_stage = stage
|
|
|
|
# Use the DB's own numeric primary key rather than the
|
|
# upstream-supplied mywhoosh_activity_id as a directory
|
|
# component: activity.id is always a safe integer, so
|
|
# this avoids any path-traversal risk from an
|
|
# unsanitized remote id (e.g. "../../etc") while
|
|
# remaining just as stable across resumed syncs.
|
|
activity_dir = self.settings.activities_dir / str(user.id) / str(activity.id)
|
|
source_path = activity_dir / "source.fit"
|
|
converted_path = activity_dir / "edge-1030-plus.fit"
|
|
|
|
try:
|
|
if stage == ActivityStatus.DISCOVERED:
|
|
fit_bytes = await mywhoosh.download_fit(
|
|
remote.activity_file_id, mw_email, mw_password
|
|
)
|
|
activity_dir.mkdir(parents=True, exist_ok=True)
|
|
source_path.write_bytes(fit_bytes)
|
|
activity = activity_repo.mark_downloaded(activity.id, str(source_path))
|
|
stage = activity.status
|
|
|
|
if stage in {ActivityStatus.DOWNLOADED}:
|
|
self.fit_converter(source_path, converted_path)
|
|
activity = activity_repo.mark_converted(activity.id, str(converted_path))
|
|
stage = activity.status
|
|
|
|
if stage in {ActivityStatus.CONVERTED}:
|
|
upload = await asyncio.to_thread(garmin.import_fit, converted_path, mfa_code)
|
|
if upload.duplicate:
|
|
activity = activity_repo.mark_duplicate(activity.id)
|
|
else:
|
|
activity = activity_repo.mark_imported(activity.id, upload.garmin_activity_id)
|
|
stage = activity.status
|
|
user.garmin_state = "connected"
|
|
garmin_succeeded_this_run = True
|
|
|
|
# Only count this activity's outcome toward this
|
|
# run's totals if the state machine actually did
|
|
# work this call. An activity that was already
|
|
# terminal (IMPORTED/DUPLICATE) before this call is
|
|
# resume history, not this run's work.
|
|
if initial_stage not in (ActivityStatus.IMPORTED, ActivityStatus.DUPLICATE):
|
|
if activity.status == ActivityStatus.IMPORTED:
|
|
imported_count += 1
|
|
elif activity.status == ActivityStatus.DUPLICATE:
|
|
skipped_count += 1
|
|
|
|
except MyWhooshTransientError as exc:
|
|
user.health_state = HealthState.DEGRADED
|
|
user.mywhoosh_state = "error"
|
|
activity_repo.mark_failed(activity.id, str(exc), retryable=True)
|
|
failed_count += 1
|
|
except MyWhooshDeviceConflictError as exc:
|
|
user.health_state = HealthState.ACTION_REQUIRED
|
|
user.mywhoosh_state = "device_conflict"
|
|
user.action_reason = "mywhoosh_device_conflict"
|
|
stop_user_run = True
|
|
summary_error = str(exc)
|
|
except MyWhooshAuthError as exc:
|
|
user.health_state = HealthState.ACTION_REQUIRED
|
|
user.mywhoosh_state = "auth_required"
|
|
user.action_reason = "mywhoosh_auth_required"
|
|
stop_user_run = True
|
|
summary_error = str(exc)
|
|
except MyWhooshIntegrationError as exc:
|
|
user.health_state = HealthState.ACTION_REQUIRED
|
|
user.mywhoosh_state = "integration_error"
|
|
user.action_reason = "mywhoosh_integration_changed"
|
|
stop_user_run = True
|
|
summary_error = str(exc)
|
|
except GarminUploadBlocked as exc:
|
|
user.health_state = HealthState.ACTION_REQUIRED
|
|
user.garmin_state = "mfa_required"
|
|
user.action_reason = "garmin_mfa_required"
|
|
stop_user_run = True
|
|
summary_error = str(exc)
|
|
except GarminAuthError as exc:
|
|
user.health_state = HealthState.ACTION_REQUIRED
|
|
user.garmin_state = "auth_required"
|
|
user.action_reason = "garmin_auth_required"
|
|
stop_user_run = True
|
|
summary_error = str(exc)
|
|
except GarminTransientError as exc:
|
|
user.health_state = HealthState.DEGRADED
|
|
user.garmin_state = "error"
|
|
activity_repo.mark_failed(activity.id, str(exc), retryable=True)
|
|
failed_count += 1
|
|
except GarminImportRejected as exc:
|
|
# Garmin-side permanent content rejection (real
|
|
# failures, no successes in detailedImportResult):
|
|
# same category as a corrupt/unsupported FIT file --
|
|
# a non-retryable per-activity failure, no user
|
|
# health/state change.
|
|
activity_repo.mark_failed(activity.id, str(exc), retryable=False)
|
|
failed_count += 1
|
|
except FitFormatError as exc:
|
|
activity_repo.mark_failed(activity.id, str(exc), retryable=False)
|
|
failed_count += 1
|
|
except Exception as exc:
|
|
user.health_state = HealthState.DEGRADED
|
|
activity_repo.mark_failed(
|
|
activity.id,
|
|
f"{type(exc).__name__}: {str(exc)[:200]}",
|
|
retryable=True,
|
|
)
|
|
failed_count += 1
|
|
logger.warning(
|
|
"sync_user: unexpected error for user %s activity %s: %s",
|
|
user.id,
|
|
activity.id,
|
|
exc.__class__.__name__,
|
|
)
|
|
|
|
session.commit()
|
|
|
|
if not stop_user_run:
|
|
# Reaching here with stop_user_run still False proves
|
|
# this run's MyWhoosh listing succeeded (every
|
|
# MyWhoosh-exception branch above also sets
|
|
# stop_user_run=True) -- so a MyWhoosh-class
|
|
# action_reason is always safe to clear here. It does
|
|
# NOT prove any Garmin problem was fixed: a Garmin-class
|
|
# action_reason may only be cleared when this run
|
|
# actually succeeded at a real Garmin import
|
|
# (garmin_succeeded_this_run). Otherwise the
|
|
# action-required condition is still live and
|
|
# unverified, so leave both action_reason and
|
|
# health_state untouched.
|
|
reason = user.action_reason
|
|
can_clear = (
|
|
reason is None
|
|
or reason in _MYWHOOSH_ACTION_REASONS
|
|
or (reason in _GARMIN_ACTION_REASONS and garmin_succeeded_this_run)
|
|
)
|
|
if can_clear:
|
|
user.action_reason = None
|
|
user.health_state = (
|
|
HealthState.DEGRADED if failed_count > 0 else HealthState.HEALTHY
|
|
)
|
|
session.commit()
|
|
|
|
status = (
|
|
SyncRunStatus.SUCCESS
|
|
if failed_count == 0 and not stop_user_run
|
|
else SyncRunStatus.PARTIAL
|
|
if (imported_count + skipped_count) > 0
|
|
else SyncRunStatus.FAILED
|
|
)
|
|
sync_run_repo.finish(
|
|
run.id,
|
|
status=status,
|
|
discovered=discovered,
|
|
imported=imported_count,
|
|
skipped=skipped_count,
|
|
failed=failed_count,
|
|
summary_error=summary_error,
|
|
)
|
|
session.commit()
|
|
if user.action_reason is not None and user.action_reason != previous_action_reason:
|
|
self._notify_action_required(session, user, summary_error)
|
|
return SyncOutcome(
|
|
user_id=user.id,
|
|
status=status.value,
|
|
discovered=discovered,
|
|
imported=imported_count,
|
|
skipped=skipped_count,
|
|
failed=failed_count,
|
|
message=summary_error,
|
|
)
|
|
except Exception as exc:
|
|
# Any unhandled exception escaping the block above (e.g. a
|
|
# credential-decrypt failure after key rotation, a factory
|
|
# constructor raising, an unexpected DB error) would
|
|
# otherwise leave this SyncRun stuck at
|
|
# status=RUNNING/finished_at=NULL forever. Finish it as
|
|
# FAILED with whatever counters we do have, then
|
|
# re-propagate so callers (e.g. sync_all_enabled's
|
|
# asyncio.gather(return_exceptions=True)) still see it.
|
|
session.rollback()
|
|
sync_run_repo.finish(
|
|
run.id,
|
|
status=SyncRunStatus.FAILED,
|
|
discovered=discovered,
|
|
imported=imported_count,
|
|
skipped=skipped_count,
|
|
failed=failed_count,
|
|
summary_error=f"{type(exc).__name__}: {str(exc)[:200]}",
|
|
)
|
|
session.commit()
|
|
raise
|
|
finally:
|
|
if mywhoosh is not None:
|
|
aclose = getattr(mywhoosh, "aclose", None)
|
|
if aclose is not None:
|
|
await aclose()
|