Skip to content

Commit 51fa6b2

Browse files
committed
sync status fixes
1 parent 94f8856 commit 51fa6b2

21 files changed

Lines changed: 150 additions & 80 deletions

TODO.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ Tracked here for visibility. PRs and issues welcome.
55
## Overall capabilitites
66

77
next:
8-
- [ ] bug: sync weirdness with current month, button updates timestamp but not activities, and month shows as synced when its not.
9-
- [ ] feat: trigger sync whenever new data is collected, for the month. use the Activity table as Source Of Truth for "matching", and ensure it stays update to date with a provider id for each activity. Update / page to show summary stats.
8+
- [ ] feat: trigger sync whenever new data is collected, for the month. use the Activity table as Source Of Truth for "matching", and ensure it stays update to date with a provider id for each activity. Update / page to show summary stats. Allow "link" when different device was used to record.
109
- [ ] feat: only users with active subscription, or admin, can go back further than current month. all other users: data expires out at start of new month.
1110
- [ ] feat: except admin, "full provider sync" is only allowed once per day.
1211
- [ ] data: 2024-02-29 19:20 garmin shows up on march 2024 review

app/routes/calendar.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,10 @@ def sync_provider_month(year_month: str, provider_name: str):
9191
409,
9292
)
9393

94+
from tracekit.provider_sync import ProviderSync, SyncStatus
9495
from tracekit.user_context import get_user_id
9596

97+
ProviderSync.upsert_status(year_month, provider_name, SyncStatus.ENQUEUED)
9698
task = pull_provider_month.delay(year_month, provider_name, user_id=get_user_id())
9799
set_pull_status(year_month, provider_name, PULL_STATUS_QUEUED, job_id=task.id)
98100
return jsonify(

app/routes/files.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ def _file_exists_in_folder(data_folder: str, filename: str) -> bool:
4747
def api_file_download():
4848
"""Serve a file activity as a download, verifying user ownership."""
4949
raw_name = request.args.get("name", "").strip()
50+
# Reject path traversal attempts before secure_filename can silently strip them
51+
if ".." in raw_name or "/" in raw_name or "\\" in raw_name:
52+
return "Invalid filename", 400
5053
# Normalize to a secure, base-name-only filename
5154
filename = secure_filename(raw_name)
5255
if not filename:

app/routes/intervalsicu_webhook.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,4 +185,11 @@ def _sync_local_activity(activity_id, user_id: int) -> None:
185185
return
186186

187187
provider = IntervalsICUProvider(config=icu_cfg)
188-
provider.sync_single_activity(str(activity_id))
188+
activity = provider.sync_single_activity(str(activity_id))
189+
if activity and activity.start_time:
190+
from datetime import UTC, datetime
191+
192+
from tracekit.provider_status import MONTH_SYNC_UNKNOWN, set_month_sync_status
193+
194+
year_month = datetime.fromtimestamp(activity.start_time, tz=UTC).strftime("%Y-%m")
195+
set_month_sync_status(year_month, MONTH_SYNC_UNKNOWN)

app/routes/ridewithgps_webhook.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,4 +229,11 @@ def _sync_local_trip(trip_id, user_id: int) -> None:
229229
return
230230

231231
provider = RideWithGPSProvider(config=rwgps_cfg)
232-
provider.sync_single_activity(str(trip_id))
232+
activity = provider.sync_single_activity(str(trip_id))
233+
if activity and activity.start_time:
234+
from datetime import UTC, datetime
235+
236+
from tracekit.provider_status import MONTH_SYNC_UNKNOWN, set_month_sync_status
237+
238+
year_month = datetime.fromtimestamp(activity.start_time, tz=UTC).strftime("%Y-%m")
239+
set_month_sync_status(year_month, MONTH_SYNC_UNKNOWN)

app/routes/strava_webhook.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,14 @@ def _sync_local_activity(activity_id, user_id: int):
225225
token_expires=strava_cfg.get("token_expires", "0"),
226226
config=strava_cfg,
227227
)
228-
provider.sync_single_activity(str(activity_id))
228+
activity = provider.sync_single_activity(str(activity_id))
229+
if activity and activity.start_time:
230+
from datetime import UTC, datetime
231+
232+
from tracekit.provider_status import MONTH_SYNC_UNKNOWN, set_month_sync_status
233+
234+
year_month = datetime.fromtimestamp(activity.start_time, tz=UTC).strftime("%Y-%m")
235+
set_month_sync_status(year_month, MONTH_SYNC_UNKNOWN)
229236

230237

231238
def _handle_deauthorize(owner_id):

app/static/calendar.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,14 @@ document.addEventListener('DOMContentLoaded', async () => {
409409
try {
410410
const res = await fetch('/api/calendar?from=' + from + '&to=' + to);
411411
const data = await res.json();
412-
months.forEach(ym => renderGrid(ym, data[ym] || { error: 'No data' }));
412+
months.forEach(ym => {
413+
renderGrid(ym, data[ym] || { error: 'No data' });
414+
const statuses = (data[ym] && data[ym].pull_statuses) || {};
415+
const anyActive = Object.values(statuses).some(
416+
s => s && (s.status === 'queued' || s.status === 'started')
417+
);
418+
if (anyActive) pollCard(ym, { minElapsed: 0 });
419+
});
413420
} catch (e) {
414421
months.forEach(ym => {
415422
const grid = document.getElementById('grid-' + ym);

app/tests/test_files_routes.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -132,21 +132,21 @@ def test_file_missing_on_disk_returns_404(self, client):
132132
assert resp.status_code == 404
133133

134134
def test_valid_file_returns_200_and_attachment(self, client):
135-
c, _ = client
136-
with tempfile.NamedTemporaryFile(suffix=".fit", delete=False) as f:
137-
f.write(b"FIT file content")
138-
tmp_path = f.name
139-
try:
135+
c, user_id = client
136+
with tempfile.TemporaryDirectory() as data_dir:
137+
user_dir = os.path.join(data_dir, "activities", str(user_id))
138+
os.makedirs(user_dir)
139+
tmp_path = os.path.join(user_dir, "activity.fit")
140+
with open(tmp_path, "wb") as f:
141+
f.write(b"FIT file content")
140142
with (
141143
patch("routes.files.FileActivity") as mock_fa,
142-
patch("routes.files._glob.glob", return_value=[tmp_path]),
144+
patch("routes.files.os.environ.get", return_value=data_dir),
143145
):
144146
mock_fa.get_or_none.return_value = MagicMock()
145147
resp = c.get("/api/file/download?name=activity.fit")
146148
assert resp.status_code == 200
147149
assert "attachment" in resp.headers.get("Content-Disposition", "")
148-
finally:
149-
os.remove(tmp_path)
150150

151151
def test_ownership_check_uses_user_id(self, client):
152152
"""get_or_none must be called with the logged-in user's ID."""

tests/providers/test_garmin.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44
from garminconnect import GarminConnectConnectionError
55

6+
from tracekit.provider_sync import SyncStatus
67
from tracekit.providers.garmin.garmin_provider import GarminProvider
78

89

@@ -114,9 +115,7 @@ def test_pull_activities_already_synced(self, mock_provider_sync):
114115
"""Test pull_activities when month is already synced."""
115116
provider = GarminProvider()
116117

117-
# Mock existing sync record
118-
mock_sync = Mock()
119-
mock_provider_sync.get_or_none.return_value = mock_sync
118+
mock_provider_sync.is_done.return_value = True
120119

121120
# Mock the _get_garmin_activities_for_month method
122121
mock_activities = [Mock(), Mock()]
@@ -126,7 +125,7 @@ def test_pull_activities_already_synced(self, mock_provider_sync):
126125

127126
# Should return activities from database without fetching new ones
128127
assert result == mock_activities
129-
mock_provider_sync.get_or_none.assert_called_once_with("2021-01", "garmin")
128+
mock_provider_sync.is_done.assert_called_once_with("2021-01", "garmin")
130129
provider._get_garmin_activities_for_month.assert_called_once_with("2021-01")
131130

132131
@pytest.mark.skip(reason="Complex mocking of GarminActivity instantiation in real execution flow")
@@ -178,7 +177,7 @@ def test_pull_activities_fetches_gear(self, mock_provider_sync, mock_garmin_acti
178177
"""Test that pull_activities calls get_activity_gear and sets equipment."""
179178
provider = GarminProvider()
180179

181-
mock_provider_sync.get_or_none.return_value = None
180+
mock_provider_sync.is_done.return_value = False
182181

183182
raw_activity = {
184183
"activityId": 12345,
@@ -210,7 +209,7 @@ def test_pull_activities_gear_api_error_does_not_abort(self, mock_provider_sync,
210209
"""Test that a gear API error does not prevent the activity from being saved."""
211210
provider = GarminProvider()
212211

213-
mock_provider_sync.get_or_none.return_value = None
212+
mock_provider_sync.is_done.return_value = False
214213

215214
raw_activity = {"activityId": 12345, "activityName": "Morning Ride"}
216215
provider.fetch_activities_for_month = Mock(return_value=[raw_activity])
@@ -237,7 +236,7 @@ def test_pull_activities_with_duplicate_activity(self, mock_provider_sync):
237236
provider = GarminProvider()
238237

239238
# Mock no existing sync record
240-
mock_provider_sync.get_or_none.return_value = None
239+
mock_provider_sync.is_done.return_value = False
241240

242241
# Mock fetching raw activities
243242
mock_raw_activity = {"activityId": 12345, "activityName": "Test Activity"}
@@ -261,7 +260,7 @@ def test_pull_activities_with_duplicate_activity(self, mock_provider_sync):
261260

262261
# Verify duplicate was skipped (save not called)
263262
mock_activity.save.assert_not_called()
264-
mock_provider_sync.create.assert_called_once_with(year_month="2021-01", provider="garmin", user_id=0)
263+
mock_provider_sync.upsert_status.assert_called_once_with("2021-01", "garmin", SyncStatus.DONE)
265264

266265

267266
class TestGarminProviderFetchActivities:

tests/providers/test_strava.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import pytest
55

6+
from tracekit.provider_sync import SyncStatus
67
from tracekit.providers.strava.strava_provider import StravaProvider
78

89

@@ -437,8 +438,7 @@ def test_pull_activities_already_synced(self, mock_provider_sync):
437438
provider = StravaProvider(token="test_token", refresh_token="test_refresh", token_expires="999999999")
438439

439440
# Mock existing sync record
440-
mock_sync = Mock()
441-
mock_provider_sync.get_or_none.return_value = mock_sync
441+
mock_provider_sync.is_done.return_value = True
442442

443443
# Mock the _get_strava_activities_for_month method
444444
mock_activities = [Mock(), Mock()]
@@ -448,7 +448,7 @@ def test_pull_activities_already_synced(self, mock_provider_sync):
448448

449449
# Should return activities from database without fetching new ones
450450
assert result == mock_activities
451-
mock_provider_sync.get_or_none.assert_called_once_with("2021-01", "strava")
451+
mock_provider_sync.is_done.assert_called_once_with("2021-01", "strava")
452452
provider._get_strava_activities_for_month.assert_called_once_with("2021-01")
453453

454454
@patch("tracekit.providers.strava.strava_provider.ProviderSync")
@@ -457,7 +457,7 @@ def test_pull_activities_new_month(self, mock_provider_sync):
457457
provider = StravaProvider(token="test_token", refresh_token="test_refresh", token_expires="999999999")
458458

459459
# Mock no existing sync record
460-
mock_provider_sync.get_or_none.return_value = None
460+
mock_provider_sync.is_done.return_value = False
461461

462462
# Mock fetching raw activities
463463
mock_raw_activities = [Mock(), Mock()]
@@ -483,17 +483,17 @@ def test_pull_activities_new_month(self, mock_provider_sync):
483483

484484
# Verify the flow
485485
assert result == mock_final_activities
486-
mock_provider_sync.get_or_none.assert_called_once_with("2021-01", "strava")
486+
mock_provider_sync.is_done.assert_called_once_with("2021-01", "strava")
487487
provider._fetch_strava_activities_for_month.assert_called_once_with("2021-01")
488-
mock_provider_sync.create.assert_called_once_with(year_month="2021-01", provider="strava", user_id=0)
488+
mock_provider_sync.upsert_status.assert_called_once_with("2021-01", "strava", SyncStatus.DONE)
489489

490490
@patch("tracekit.providers.strava.strava_provider.ProviderSync")
491491
def test_pull_activities_with_duplicate_activity(self, mock_provider_sync):
492492
"""Test pull_activities when encountering duplicate activity."""
493493
provider = StravaProvider(token="test_token", refresh_token="test_refresh", token_expires="999999999")
494494

495495
# Mock no existing sync record
496-
mock_provider_sync.get_or_none.return_value = None
496+
mock_provider_sync.is_done.return_value = False
497497

498498
# Mock fetching raw activities
499499
mock_raw_activity = Mock()
@@ -517,15 +517,15 @@ def test_pull_activities_with_duplicate_activity(self, mock_provider_sync):
517517

518518
# Verify duplicate was skipped (save not called)
519519
mock_converted_activity.save.assert_not_called()
520-
mock_provider_sync.create.assert_called_once_with(year_month="2021-01", provider="strava", user_id=0)
520+
mock_provider_sync.upsert_status.assert_called_once_with("2021-01", "strava", SyncStatus.DONE)
521521

522522
@patch("tracekit.providers.strava.strava_provider.ProviderSync")
523523
def test_pull_activities_with_error(self, mock_provider_sync):
524524
"""Test pull_activities when processing error occurs."""
525525
provider = StravaProvider(token="test_token", refresh_token="test_refresh", token_expires="999999999")
526526

527527
# Mock no existing sync record
528-
mock_provider_sync.get_or_none.return_value = None
528+
mock_provider_sync.is_done.return_value = False
529529

530530
# Mock fetching raw activities
531531
mock_raw_activity = Mock()
@@ -542,7 +542,7 @@ def test_pull_activities_with_error(self, mock_provider_sync):
542542

543543
# Should handle error gracefully and still mark as synced
544544
assert result == mock_final_activities
545-
mock_provider_sync.create.assert_called_once_with(year_month="2021-01", provider="strava", user_id=0)
545+
mock_provider_sync.upsert_status.assert_called_once_with("2021-01", "strava", SyncStatus.DONE)
546546

547547
@patch("tracekit.providers.strava.strava_provider.ProviderSync")
548548
def test_pull_activities_rate_limit_propagates(self, mock_provider_sync):
@@ -554,7 +554,7 @@ def test_pull_activities_rate_limit_propagates(self, mock_provider_sync):
554554

555555
provider = StravaProvider(token="test_token", refresh_token="test_refresh", token_expires="999999999")
556556

557-
mock_provider_sync.get_or_none.return_value = None
557+
mock_provider_sync.is_done.return_value = False
558558

559559
mock_raw_activity = Mock()
560560
provider._fetch_strava_activities_for_month = Mock(return_value=[mock_raw_activity])
@@ -574,7 +574,7 @@ def test_pull_activities_rate_limit_propagates(self, mock_provider_sync):
574574
provider.pull_activities(date_filter="2021-01")
575575

576576
# The month must NOT be marked as synced when a rate-limit aborts the pull.
577-
mock_provider_sync.create.assert_not_called()
577+
mock_provider_sync.upsert_status.assert_not_called()
578578

579579

580580
class TestStravaProviderCreateActivity:

0 commit comments

Comments
 (0)