Skip to content

Commit 70383b8

Browse files
committed
Adds garmin gear upsert
1 parent 22b067f commit 70383b8

4 files changed

Lines changed: 77 additions & 15 deletions

File tree

TODO.md

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

77
next:
8+
- [ ] bug: worker logs are only on stdout, not in sentry logs
89
- [ ] feat: "add" to spreadsheet data
910
- [ ] feat: on sync, use Activty table to link up to all provider activities
1011
- [ ] bug: pulling provider in ui for a month doesn't get new activites in the month

app/static/settings.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ const PROVIDER_META = {
3232
],
3333
},
3434
garmin: { label: 'Garmin', sync_equipment: true, sync_name: true,
35-
instructions: 'Garmin authentication tokens are valid for approximately one year. Re-authenticate before they expire to avoid interruption.',
35+
instructions: 'Garmin authentication tokens are valid for approximately one year. Re-authenticate before they expire to avoid interruption. ' +
36+
'When syncing equipment, if a gear name does not already exist in your Garmin account it will be created automatically using the name as both the display name and make/model.',
3637
text_fields: [] },
3738
spreadsheet: {
3839
label: 'Spreadsheet', sync_equipment: true, sync_name: true,

tests/providers/test_garmin.py

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -417,19 +417,59 @@ def test_set_gear_removes_existing_gear(self):
417417
mock_client.remove_gear_from_activity.assert_called_once_with("uuid-old", "99999")
418418
mock_client.add_gear_to_activity.assert_called_once_with("uuid-new", "99999")
419419

420-
def test_set_gear_gear_not_found(self):
421-
"""Test set_gear returns False when gear name is not in account."""
420+
def test_create_gear_success(self):
421+
"""Test _create_gear POSTs to Garmin API and returns the UUID."""
422422
provider = GarminProvider()
423423

424424
mock_client = Mock()
425425
mock_client.get_device_last_used.return_value = {"userProfileNumber": "12345"}
426-
mock_client.get_gear.return_value = [{"uuid": "uuid-bike", "displayName": "Trek Bike"}]
426+
mock_client.garth.post.return_value.json.return_value = {"uuid": "new-uuid-123", "displayName": "New Bike"}
427427
provider._get_client = Mock(return_value=mock_client)
428428

429-
result = provider.set_gear("Unknown Gear", "99999")
429+
uuid = provider._create_gear("New Bike")
430+
431+
assert uuid == "new-uuid-123"
432+
mock_client.garth.post.assert_called_once_with(
433+
"connectapi",
434+
"/gear-service/gear",
435+
json={
436+
"displayName": "New Bike",
437+
"customMakeModel": "New Bike",
438+
"gearStatusName": "active",
439+
"userProfilePk": "12345",
440+
},
441+
)
430442

431-
assert result is False
432-
mock_client.add_gear_to_activity.assert_not_called()
443+
def test_create_gear_no_uuid_raises(self):
444+
"""Test _create_gear raises if the API response contains no UUID."""
445+
provider = GarminProvider()
446+
447+
mock_client = Mock()
448+
mock_client.get_device_last_used.return_value = {"userProfileNumber": "12345"}
449+
mock_client.garth.post.return_value.json.return_value = {}
450+
provider._get_client = Mock(return_value=mock_client)
451+
452+
with pytest.raises(RuntimeError, match="no UUID"):
453+
provider._create_gear("New Bike")
454+
455+
def test_set_gear_creates_when_not_found(self):
456+
"""Test set_gear creates the gear in Garmin Connect if it doesn't exist yet."""
457+
provider = GarminProvider()
458+
459+
mock_client = Mock()
460+
mock_client.get_device_last_used.return_value = {"userProfileNumber": "12345"}
461+
mock_client.get_gear.return_value = [] # no existing gear
462+
mock_client.garth.post.return_value.json.return_value = {"uuid": "new-uuid-abc"}
463+
mock_client.get_activity_gear.return_value = []
464+
provider._get_client = Mock(return_value=mock_client)
465+
466+
with patch("tracekit.providers.garmin.garmin_provider.GarminActivity") as mock_act_cls:
467+
mock_act_cls.get_or_none.return_value = None
468+
result = provider.set_gear("Brand New Bike", "99999")
469+
470+
assert result is True
471+
mock_client.garth.post.assert_called_once()
472+
mock_client.add_gear_to_activity.assert_called_once_with("new-uuid-abc", "99999")
433473

434474
def test_set_gear_api_error(self):
435475
"""Test set_gear returns False on API error."""

tracekit/providers/garmin/garmin_provider.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -399,23 +399,43 @@ def _find_gear_uuid(self, gear_name: str) -> str | None:
399399
return gear_item.get("uuid")
400400
return None
401401

402+
def _create_gear(self, gear_name: str) -> str:
403+
"""Create a new gear item in Garmin Connect and return its UUID.
404+
405+
Uses the gear name as both displayName and customMakeModel — Garmin
406+
requires these fields but doesn't enforce their values.
407+
"""
408+
client = self._get_client()
409+
device_last_used = client.get_device_last_used()
410+
user_profile_pk = device_last_used["userProfileNumber"]
411+
payload = {
412+
"displayName": gear_name,
413+
"customMakeModel": gear_name,
414+
"gearStatusName": "active",
415+
"userProfilePk": user_profile_pk,
416+
}
417+
result = client.garth.post("connectapi", "/gear-service/gear", json=payload).json()
418+
uuid = result.get("uuid")
419+
if not uuid:
420+
raise RuntimeError(f"Garmin gear creation returned no UUID: {result!r}")
421+
print(f"Created Garmin gear '{gear_name}' with UUID {uuid}")
422+
return uuid
423+
402424
def set_gear(self, gear_name: str, activity_id: str) -> bool:
403425
"""Set gear for an activity on Garmin Connect.
404426
405-
Looks up the gear UUID by display name, removes any existing gear from
406-
the activity, then adds the new gear. Also upserts the local
407-
GarminActivity.equipment field so the DB stays in sync.
408-
409-
Returns True on success, False if the gear name is not found in the
410-
user's Garmin account.
427+
Looks up the gear UUID by display name; if not found, creates the gear
428+
first. Removes any existing gear from the activity, then adds the new
429+
gear. Also upserts the local GarminActivity.equipment field so the DB
430+
stays in sync.
411431
"""
412432
try:
413433
client = self._get_client()
414434

415435
gear_uuid = self._find_gear_uuid(gear_name)
416436
if not gear_uuid:
417-
print(f"Gear '{gear_name}' not found in Garmin Connect account")
418-
return False
437+
print(f"Gear '{gear_name}' not found — creating it in Garmin Connect")
438+
gear_uuid = self._create_gear(gear_name)
419439

420440
# Remove any gear currently associated with this activity
421441
try:

0 commit comments

Comments
 (0)