diff --git a/.gitignore b/.gitignore index 0b33d80..83e6fff 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ __pycache__/ build/ dist/ logs/ +.venv/ +scripts/strava_token.txt diff --git a/cdn/static/web/launcher/intervals.html b/cdn/static/web/launcher/intervals.html index 286aaea..7034212 100644 --- a/cdn/static/web/launcher/intervals.html +++ b/cdn/static/web/launcher/intervals.html @@ -8,6 +8,7 @@
Back {% if aid or akey %} + Sync today's workout Remove credentials {% endif %}
diff --git a/cdn/static/web/launcher/settings.html b/cdn/static/web/launcher/settings.html index 91fa9ec..d7db488 100644 --- a/cdn/static/web/launcher/settings.html +++ b/cdn/static/web/launcher/settings.html @@ -14,6 +14,7 @@ Zwift Strava Intervals + TrainingPeaks {% if files %} diff --git a/cdn/static/web/launcher/trainingpeaks.html b/cdn/static/web/launcher/trainingpeaks.html new file mode 100644 index 0000000..91d933e --- /dev/null +++ b/cdn/static/web/launcher/trainingpeaks.html @@ -0,0 +1,49 @@ +{% extends "./layout.html" %} +{% block content %} +

TrainingPeaks

+ {% if username != "zoffline" %} +

Logged in as {{ username }}

+ {% endif %} +
+
+ Back + Import workouts +
+
+
+
+
+
+
{{ message }}
+
+
+
Manual bridge mode: export TrainingPeaks workouts as .zwo files into a local folder, save that folder path here, then click "Import workouts".
+
+
+
+
+
+ + +
+
+
+
+ +
+
+
+ {% with messages = get_flashed_messages() %} + {% if messages %} + + {% endif %} + {% endwith %} +
+
+{% endblock %} diff --git a/intervals_workouts.py b/intervals_workouts.py new file mode 100644 index 0000000..59863c7 --- /dev/null +++ b/intervals_workouts.py @@ -0,0 +1,135 @@ +import datetime +import re +from io import BytesIO +from urllib.parse import urlencode + +from requests.auth import HTTPBasicAuth + + +DEFAULT_BASE_URL = "https://intervals.icu/api/v1" + + +def normalize_events_payload(payload): + if isinstance(payload, list): + return payload + if isinstance(payload, dict): + for key in ("events", "data", "items"): + value = payload.get(key) + if isinstance(value, list): + return value + return [] + + +def parse_datetime(value): + if not value: + return None + if isinstance(value, datetime.datetime): + parsed = value + else: + if value.endswith("Z"): + value = value[:-1] + "+00:00" + try: + parsed = datetime.datetime.fromisoformat(value) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=datetime.timezone.utc) + return parsed + + +def parse_event_datetime(event): + value = event.get("start_date_local") or event.get("start_date") or event.get("date") + return parse_datetime(value) + + +def choose_workout_event(events, now=None): + if not events: + return None + now = now or datetime.datetime.now(datetime.timezone.utc) + ordered = sorted(events, key=lambda event: parse_event_datetime(event) or datetime.datetime.max.replace(tzinfo=datetime.timezone.utc)) + future = [event for event in ordered if (parse_event_datetime(event) or now) >= now] + if future: + return future[0] + return ordered[-1] + + +def slugify_filename(value): + value = (value or "").strip().lower() + value = re.sub(r"[^a-z0-9]+", "-", value) + return value.strip("-") + + +def build_workout_filename(event): + event_id = event.get("id", "workout") + name = slugify_filename(event.get("name") or f"workout-{event_id}") + if not name: + name = f"workout-{event_id}" + return f"intervals-icu-{event_id}-{name}.zwo" + + +def build_activity_upload_url(base_url, athlete_id, activity_name, paired_event_id=None): + params = {"name": activity_name} + if paired_event_id: + params["paired_event_id"] = paired_event_id + return f"{base_url}/athlete/{athlete_id}/activities?{urlencode(params)}" + + +def activity_matches_workout(activity, workout_metadata): + if not workout_metadata: + return False + event_dt = parse_datetime(workout_metadata.get("start_date_local") or workout_metadata.get("start_date") or workout_metadata.get("synced_for_date")) + activity_dt = parse_datetime(getattr(activity, "start_date", None) or getattr(activity, "date", None)) + if not event_dt or not activity_dt: + return False + return event_dt.date() == activity_dt.date() + + +def upload_activity(athlete_id, api_key, activity, workout_metadata=None, session=None, base_url=DEFAULT_BASE_URL): + session = session or __import__("requests") + paired_event_id = None + if activity_matches_workout(activity, workout_metadata): + paired_event_id = workout_metadata.get("event_id") or workout_metadata.get("id") + upload_url = build_activity_upload_url(base_url, athlete_id, activity.name, paired_event_id=paired_event_id) + upload_response = session.post(upload_url, files={"file": BytesIO(activity.fit)}, auth=HTTPBasicAuth("API_KEY", api_key), timeout=30) + upload_response.raise_for_status() + if paired_event_id: + mark_done_url = f"{base_url}/athlete/{athlete_id}/events/{paired_event_id}/mark-done" + mark_done_response = session.post(mark_done_url, auth=HTTPBasicAuth("API_KEY", api_key), timeout=30) + mark_done_response.raise_for_status() + return { + "status": "uploaded", + "paired": bool(paired_event_id), + "paired_event_id": paired_event_id, + } + + +def sync_workout(athlete_id, api_key, store_workout, today=None, now=None, session=None, base_url=DEFAULT_BASE_URL): + today = today or datetime.date.today() + now = now or datetime.datetime.now(datetime.timezone.utc) + session = session or __import__("requests") + + query = urlencode({ + "oldest": today.isoformat(), + "newest": today.isoformat(), + "category": "WORKOUT", + }) + events_url = f"{base_url}/athlete/{athlete_id}/events?{query}" + events_response = session.get(events_url, auth=HTTPBasicAuth("API_KEY", api_key), timeout=30) + events_response.raise_for_status() + events = normalize_events_payload(events_response.json()) + event = choose_workout_event(events, now=now) + if not event: + return {"status": "no_workout", "message": f"No Intervals.icu workout found for {today.isoformat()}"} + + workout_url = f"{base_url}/athlete/{athlete_id}/events/{event['id']}/download.zwo" + workout_response = session.get(workout_url, auth=HTTPBasicAuth("API_KEY", api_key), timeout=30) + workout_response.raise_for_status() + + filename = build_workout_filename(event) + store_workout(filename, workout_response.content, event) + return { + "status": "synced", + "event": event, + "filename": filename, + "message": f"Synced Intervals.icu workout: {event.get('name', filename)}", + } diff --git a/tests/test_intervals_workouts.py b/tests/test_intervals_workouts.py new file mode 100644 index 0000000..2a40a9b --- /dev/null +++ b/tests/test_intervals_workouts.py @@ -0,0 +1,152 @@ +import datetime +import unittest +from types import SimpleNamespace + +from intervals_workouts import sync_workout, upload_activity + + +class FakeResponse: + def __init__(self, payload=None, content=b"", status_code=200): + self._payload = payload + self.content = content + self.status_code = status_code + + def json(self): + return self._payload + + def raise_for_status(self): + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + +class FakeSession: + def __init__(self, responses=None): + self.responses = list(responses or []) + self.calls = [] + + def _pop(self): + return self.responses.pop(0) + + def get(self, url, **kwargs): + self.calls.append(("GET", url, kwargs)) + return self._pop() + + def post(self, url, **kwargs): + self.calls.append(("POST", url, kwargs)) + return self._pop() + + +class SyncWorkoutTests(unittest.TestCase): + def test_sync_workout_downloads_next_planned_workout_and_saves_it(self): + now = datetime.datetime(2026, 4, 22, 10, 30, tzinfo=datetime.timezone.utc) + session = FakeSession([ + FakeResponse(payload={ + "events": [ + { + "id": 41, + "name": "Morning opener", + "start_date_local": "2026-04-22T08:00:00+00:00", + }, + { + "id": 42, + "name": "Threshold Session", + "start_date_local": "2026-04-22T18:00:00+00:00", + }, + ] + }), + FakeResponse(content=b""), + ]) + saved = [] + + result = sync_workout( + athlete_id="123", + api_key="secret", + store_workout=lambda filename, content, event: saved.append((filename, content, event)), + today=now.date(), + now=now, + session=session, + ) + + self.assertEqual(result["status"], "synced") + self.assertEqual(result["event"]["id"], 42) + self.assertEqual(saved[0][0], "intervals-icu-42-threshold-session.zwo") + self.assertEqual(saved[0][1], b"") + self.assertEqual(saved[0][2]["id"], 42) + self.assertIn("/athlete/123/events?oldest=2026-04-22", session.calls[0][1]) + self.assertTrue(session.calls[1][1].endswith("/athlete/123/events/42/download.zwo")) + + def test_sync_workout_returns_no_workout_when_none_found(self): + today = datetime.date(2026, 4, 22) + session = FakeSession([FakeResponse(payload=[])]) + saved = [] + + result = sync_workout( + athlete_id="123", + api_key="secret", + store_workout=lambda filename, content, event: saved.append((filename, content, event)), + today=today, + session=session, + ) + + self.assertEqual(result["status"], "no_workout") + self.assertEqual(saved, []) + self.assertEqual(len(session.calls), 1) + + def test_upload_activity_pairs_synced_workout_and_marks_event_done(self): + activity = SimpleNamespace( + name="VO2 ride", + fit=b"FITDATA", + start_date="2026-04-22T18:05:00Z", + ) + metadata = { + "event_id": 42, + "start_date_local": "2026-04-22T18:00:00+00:00", + "filename": "intervals-icu-42-threshold-session.zwo", + } + session = FakeSession([FakeResponse(), FakeResponse()]) + + result = upload_activity( + athlete_id="123", + api_key="secret", + activity=activity, + workout_metadata=metadata, + session=session, + ) + + self.assertEqual(result["status"], "uploaded") + self.assertTrue(result["paired"]) + self.assertIn("paired_event_id=42", session.calls[0][1]) + self.assertEqual(session.calls[0][0], "POST") + self.assertEqual(session.calls[1][1], "https://intervals.icu/api/v1/athlete/123/events/42/mark-done") + uploaded = session.calls[0][2]["files"]["file"] + self.assertEqual(uploaded.read(), b"FITDATA") + + def test_upload_activity_without_matching_synced_workout_skips_pairing(self): + activity = SimpleNamespace( + name="VO2 ride", + fit=b"FITDATA", + start_date="2026-04-23T06:00:00Z", + ) + metadata = { + "event_id": 42, + "start_date_local": "2026-04-22T18:00:00+00:00", + "filename": "intervals-icu-42-threshold-session.zwo", + } + session = FakeSession([FakeResponse()]) + + result = upload_activity( + athlete_id="123", + api_key="secret", + activity=activity, + workout_metadata=metadata, + session=session, + ) + + self.assertEqual(result["status"], "uploaded") + self.assertFalse(result["paired"]) + self.assertNotIn("paired_event_id=42", session.calls[0][1]) + self.assertEqual(len(session.calls), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_trainingpeaks_workouts.py b/tests/test_trainingpeaks_workouts.py new file mode 100644 index 0000000..32c05bf --- /dev/null +++ b/tests/test_trainingpeaks_workouts.py @@ -0,0 +1,55 @@ +import os +import tempfile +import unittest +from types import SimpleNamespace + +from trainingpeaks_workouts import ( + build_workout_filename, + sync_exported_workouts, + sync_workout, + upload_activity, +) + + +class TrainingPeaksWorkoutTests(unittest.TestCase): + def test_build_workout_filename_uses_trainingpeaks_prefix(self): + workout = {'id': 51, 'title': 'VO2 Max Builder'} + self.assertEqual(build_workout_filename(workout), 'trainingpeaks-51-vo2-max-builder.zwo') + + def test_sync_exported_workouts_imports_zwo_files_only(self): + with tempfile.TemporaryDirectory() as tmp: + with open(os.path.join(tmp, 'VO2 Builder.zwo'), 'wb') as fd: + fd.write(b'') + with open(os.path.join(tmp, 'Tempo Ride.zwo'), 'wb') as fd: + fd.write(b'') + with open(os.path.join(tmp, 'ignore.txt'), 'w') as fd: + fd.write('ignore me') + + saved = [] + result = sync_exported_workouts(tmp, lambda filename, content, workout: saved.append((filename, content, workout))) + + self.assertEqual(result['status'], 'synced') + self.assertEqual(result['count'], 2) + self.assertEqual([item[0] for item in saved], [ + 'trainingpeaks-tempo-ride.zwo', + 'trainingpeaks-vo2-builder.zwo', + ]) + + def test_sync_exported_workouts_handles_missing_folder(self): + result = sync_exported_workouts('/tmp/does-not-exist-hermes', lambda *args: None) + self.assertEqual(result['status'], 'missing_folder') + + def test_sync_workout_reports_partner_access_requirement(self): + result = sync_workout(credentials={'client_id': 'abc'}) + self.assertEqual(result['status'], 'unsupported') + self.assertIn('partner', result['message'].lower()) + + def test_upload_activity_reports_partner_access_requirement(self): + activity = SimpleNamespace(name='Ride', fit=b'FIT') + result = upload_activity(credentials={'access_token': 'abc'}, activity=activity) + self.assertEqual(result['status'], 'unsupported') + self.assertIn('partner', result['message'].lower()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_workout_state.py b/tests/test_workout_state.py new file mode 100644 index 0000000..6270371 --- /dev/null +++ b/tests/test_workout_state.py @@ -0,0 +1,73 @@ +import json +import os +import tempfile +import unittest + +from workout_state import ( + clear_metadata, + load_active_provider, + load_metadata, + metadata_file, + resolve_active_provider, + save_active_provider, + save_metadata, +) + + +class WorkoutStateTests(unittest.TestCase): + def test_metadata_file_is_provider_specific(self): + path = metadata_file('/tmp/storage', 7, 'intervals-icu') + self.assertEqual(path, '/tmp/storage/7/intervals_icu_workout.json') + + def test_save_and_load_metadata_round_trip(self): + with tempfile.TemporaryDirectory() as tmp: + payload = save_metadata( + tmp, + 1, + 'intervals-icu', + { + 'id': 42, + 'name': 'Threshold Session', + 'start_date_local': '2026-04-22T18:00:00+00:00', + }, + 'intervals-icu-42-threshold-session.zwo', + ) + self.assertEqual(payload['provider'], 'intervals-icu') + self.assertEqual(payload['event_id'], 42) + self.assertEqual(payload['filename'], 'intervals-icu-42-threshold-session.zwo') + + loaded = load_metadata(tmp, 1, 'intervals-icu') + self.assertEqual(loaded['event_id'], 42) + self.assertEqual(loaded['provider'], 'intervals-icu') + + def test_clear_metadata_removes_only_selected_provider(self): + with tempfile.TemporaryDirectory() as tmp: + save_metadata(tmp, 1, 'intervals-icu', {'id': 42, 'name': 'A'}, 'a.zwo') + save_metadata(tmp, 1, 'trainingpeaks', {'id': 99, 'name': 'B'}, 'b.zwo') + + clear_metadata(tmp, 1, 'intervals-icu') + + self.assertIsNone(load_metadata(tmp, 1, 'intervals-icu')) + self.assertEqual(load_metadata(tmp, 1, 'trainingpeaks')['event_id'], 99) + + def test_save_and_load_active_provider(self): + with tempfile.TemporaryDirectory() as tmp: + self.assertIsNone(load_active_provider(tmp, 1)) + save_active_provider(tmp, 1, 'trainingpeaks') + self.assertEqual(load_active_provider(tmp, 1), 'trainingpeaks') + + def test_resolve_active_provider_uses_saved_provider_when_available(self): + active = resolve_active_provider('trainingpeaks', {'intervals-icu', 'trainingpeaks'}) + self.assertEqual(active, 'trainingpeaks') + + def test_resolve_active_provider_falls_back_to_first_available(self): + active = resolve_active_provider('trainingpeaks', {'intervals-icu'}) + self.assertEqual(active, 'intervals-icu') + + def test_resolve_active_provider_prefers_intervals_when_nothing_saved(self): + active = resolve_active_provider(None, {'trainingpeaks', 'intervals-icu'}) + self.assertEqual(active, 'intervals-icu') + + +if __name__ == '__main__': + unittest.main() diff --git a/trainingpeaks_workouts.py b/trainingpeaks_workouts.py new file mode 100644 index 0000000..d620e99 --- /dev/null +++ b/trainingpeaks_workouts.py @@ -0,0 +1,78 @@ +import os +import re + + +PARTNER_ACCESS_MESSAGE = ( + 'TrainingPeaks automatic sync requires approved partner API access and app credentials. ' + 'This provider is scaffolded but not yet live-configured.' +) + + +def slugify_filename(value): + value = (value or '').strip().lower() + value = re.sub(r'[^a-z0-9]+', '-', value) + return value.strip('-') + + +def build_workout_filename(workout): + workout_id = workout.get('id', 'workout') + name = slugify_filename(workout.get('name') or workout.get('title') or f'workout-{workout_id}') + if not name: + name = f'workout-{workout_id}' + return f'trainingpeaks-{workout_id}-{name}.zwo' + + +def build_exported_filename(path): + base = os.path.splitext(os.path.basename(path))[0] + slug = slugify_filename(base) + if not slug: + slug = 'workout' + return f'trainingpeaks-{slug}.zwo' + + +def sync_exported_workouts(folder, store_workout): + if not folder or not os.path.isdir(folder): + return { + 'status': 'missing_folder', + 'message': 'TrainingPeaks bridge folder is missing or unreadable.', + } + entries = [] + for name in sorted(os.listdir(folder), key=lambda item: item.lower()): + path = os.path.join(folder, name) + if not os.path.isfile(path) or not name.lower().endswith('.zwo'): + continue + with open(path, 'rb') as fd: + content = fd.read() + filename = build_exported_filename(path) + workout = {'title': os.path.splitext(name)[0], 'source_path': path} + store_workout(filename, content, workout) + entries.append(filename) + if not entries: + return { + 'status': 'no_workout', + 'message': 'No .zwo workouts were found in the TrainingPeaks bridge folder.', + 'count': 0, + } + return { + 'status': 'synced', + 'message': f'Imported {len(entries)} TrainingPeaks workout(s) from bridge folder.', + 'count': len(entries), + 'filenames': entries, + } + + +def sync_workout(credentials=None, **kwargs): + return { + 'status': 'unsupported', + 'message': PARTNER_ACCESS_MESSAGE, + 'credentials_present': bool(credentials), + } + + +def upload_activity(credentials=None, activity=None, **kwargs): + return { + 'status': 'unsupported', + 'message': PARTNER_ACCESS_MESSAGE, + 'credentials_present': bool(credentials), + 'activity_present': activity is not None, + } diff --git a/workout_state.py b/workout_state.py new file mode 100644 index 0000000..29b33cb --- /dev/null +++ b/workout_state.py @@ -0,0 +1,80 @@ +import datetime +import json +import os +import re + + +def normalize_provider_name(provider): + return re.sub(r'[^a-z0-9]+', '_', provider.lower()).strip('_') or 'provider' + + +def metadata_file(storage_dir, player_id, provider): + return os.path.join(storage_dir, str(player_id), f'{normalize_provider_name(provider)}_workout.json') + + +def build_metadata(provider, workout, filename): + return { + 'provider': provider, + 'event_id': workout.get('event_id', workout.get('id')), + 'name': workout.get('name', workout.get('title')), + 'filename': filename, + 'start_date_local': workout.get('start_date_local'), + 'start_date': workout.get('start_date'), + 'synced_at': datetime.datetime.now(datetime.timezone.utc).isoformat(), + } + + +def save_metadata(storage_dir, player_id, provider, workout, filename): + payload = build_metadata(provider, workout, filename) + file = metadata_file(storage_dir, player_id, provider) + os.makedirs(os.path.dirname(file), exist_ok=True) + with open(file, 'w') as fd: + json.dump(payload, fd) + return payload + + +def load_metadata(storage_dir, player_id, provider): + file = metadata_file(storage_dir, player_id, provider) + if not os.path.exists(file): + return None + try: + with open(file) as fd: + return json.load(fd) + except Exception: + return None + + +def clear_metadata(storage_dir, player_id, provider): + file = metadata_file(storage_dir, player_id, provider) + if os.path.exists(file): + os.remove(file) + + +def active_provider_file(storage_dir, player_id): + return os.path.join(storage_dir, str(player_id), 'active_workout_provider.txt') + + +def save_active_provider(storage_dir, player_id, provider): + file = active_provider_file(storage_dir, player_id) + os.makedirs(os.path.dirname(file), exist_ok=True) + with open(file, 'w') as fd: + fd.write((provider or '').strip()) + + +def load_active_provider(storage_dir, player_id): + file = active_provider_file(storage_dir, player_id) + if not os.path.exists(file): + return None + with open(file) as fd: + provider = fd.read().strip() + return provider or None + + +def resolve_active_provider(saved_provider, available_providers): + available = set(available_providers or set()) + if saved_provider and saved_provider in available: + return saved_provider + for candidate in ('intervals-icu', 'trainingpeaks'): + if candidate in available: + return candidate + return None diff --git a/zwift_offline.py b/zwift_offline.py index c9eafc9..111f7aa 100644 --- a/zwift_offline.py +++ b/zwift_offline.py @@ -62,6 +62,9 @@ import fitness_pb2 import structured_events_pb2 import online_sync +import intervals_workouts +import trainingpeaks_workouts +import workout_state logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO")) logger = logging.getLogger('zoffline') @@ -860,6 +863,163 @@ def backup_file(file): if os.path.isfile(file): copyfile(file, "%s-%s.bak" % (file, datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d-%H-%M-%S"))) + +def save_player_zfile(player_id, folder, filename, content): + zfiles_dir = os.path.join(STORAGE_DIR, str(player_id), folder) + if not make_dir(zfiles_dir): + raise IOError("failed to create zfiles directory") + with open(os.path.join(zfiles_dir, filename), 'wb') as fd: + fd.write(content) + timestamp = int(time.time()) + row = Zfile.query.filter_by(folder=folder, filename=filename, player_id=player_id).first() + if not row: + row = Zfile(folder=folder, filename=filename, timestamp=timestamp, player_id=player_id) + db.session.add(row) + else: + row.timestamp = timestamp + db.session.commit() + return row + + +def remove_player_zfiles_by_prefix(player_id, folder, prefix): + rows = Zfile.query.filter_by(folder=folder, player_id=player_id) + removed = False + for row in rows: + if not row.filename.startswith(prefix): + continue + removed = True + try: + os.remove(os.path.join(STORAGE_DIR, str(row.player_id), row.folder, row.filename)) + except FileNotFoundError: + pass + except Exception as exc: + logger.warning('remove_player_zfiles_by_prefix: %s' % repr(exc)) + db.session.delete(row) + if removed: + db.session.commit() + + +def load_workout_metadata(player_id, provider): + payload = workout_state.load_metadata(STORAGE_DIR, player_id, provider) + if payload is None: + return None + return payload + + +def save_workout_metadata(player_id, provider, event, filename): + return workout_state.save_metadata(STORAGE_DIR, player_id, provider, event, filename) + + +def clear_workout_metadata(player_id, provider): + workout_state.clear_metadata(STORAGE_DIR, player_id, provider) + + +def load_intervals_workout_metadata(player_id): + return load_workout_metadata(player_id, 'intervals-icu') + + +def save_intervals_workout_metadata(player_id, event, filename): + return save_workout_metadata(player_id, 'intervals-icu', event, filename) + + +def clear_intervals_workout_metadata(player_id): + clear_workout_metadata(player_id, 'intervals-icu') + + +def load_active_workout_provider(player_id): + return workout_state.load_active_provider(STORAGE_DIR, player_id) + + +def save_active_workout_provider(player_id, provider): + workout_state.save_active_provider(STORAGE_DIR, player_id, provider) + + +def available_workout_providers_for_player(player_id): + providers = set() + intervals_credentials = '%s/%s/intervals_credentials.bin' % (STORAGE_DIR, player_id) + if os.path.exists(intervals_credentials): + providers.add('intervals-icu') + if load_trainingpeaks_bridge_folder(player_id): + providers.add('trainingpeaks') + return providers + + +def resolve_workout_provider_for_player(player_id): + return workout_state.resolve_active_provider(load_active_workout_provider(player_id), available_workout_providers_for_player(player_id)) + + +def activate_workout_provider(player_id, provider): + save_active_workout_provider(player_id, provider) + if provider == 'intervals-icu': + remove_player_zfiles_by_prefix(player_id, 'customworkouts', 'trainingpeaks-') + elif provider == 'trainingpeaks': + remove_player_zfiles_by_prefix(player_id, 'customworkouts', 'intervals-icu-') + clear_intervals_workout_metadata(player_id) + + +def sync_intervals_workout_for_player(player_id): + intervals_credentials = '%s/%s/intervals_credentials.bin' % (STORAGE_DIR, player_id) + if not os.path.exists(intervals_credentials): + return {"status": "missing_credentials", "message": "Intervals.icu credentials are not configured."} + athlete_id, api_key = decrypt_credentials(intervals_credentials) + if not athlete_id or not api_key: + return {"status": "missing_credentials", "message": "Intervals.icu credentials are incomplete."} + + activate_workout_provider(player_id, 'intervals-icu') + + def store_workout(filename, content, event): + remove_player_zfiles_by_prefix(player_id, 'customworkouts', 'intervals-icu-') + save_player_zfile(player_id, 'customworkouts', filename, content) + save_intervals_workout_metadata(player_id, event, filename) + + try: + result = intervals_workouts.sync_workout(athlete_id, api_key, store_workout) + if result['status'] == 'no_workout': + clear_intervals_workout_metadata(player_id) + return result + except Exception as exc: + logger.warning('sync_intervals_workout_for_player: %s' % repr(exc)) + return {"status": "error", "message": "Intervals.icu workout sync failed."} + + +def trainingpeaks_bridge_folder_file(player_id): + return os.path.join(STORAGE_DIR, str(player_id), 'trainingpeaks_bridge_folder.txt') + + +def load_trainingpeaks_bridge_folder(player_id): + file = trainingpeaks_bridge_folder_file(player_id) + if not os.path.exists(file): + return '' + with open(file) as fd: + return fd.read().strip() + + +def save_trainingpeaks_bridge_folder(player_id, folder): + file = trainingpeaks_bridge_folder_file(player_id) + with open(file, 'w') as fd: + fd.write(folder.strip()) + + +def sync_trainingpeaks_workout_for_player(player_id): + folder = load_trainingpeaks_bridge_folder(player_id) + if not folder: + return { + 'status': 'missing_folder', + 'message': 'Set a TrainingPeaks bridge folder first. Export .zwo workouts there, then sync again.', + } + + activate_workout_provider(player_id, 'trainingpeaks') + remove_player_zfiles_by_prefix(player_id, 'customworkouts', 'trainingpeaks-') + + def store_workout(filename, content, workout): + save_player_zfile(player_id, 'customworkouts', filename, content) + + try: + return trainingpeaks_workouts.sync_exported_workouts(folder, store_workout) + except Exception as exc: + logger.warning('sync_trainingpeaks_workout_for_player: %s' % repr(exc)) + return {'status': 'error', 'message': 'TrainingPeaks bridge sync failed.'} + @app.route("/profile//", methods=["GET", "POST"]) @login_required def profile(username): @@ -960,6 +1120,38 @@ def intervals(username): return render_template("intervals.html", username=current_user.username, aid=cred[0], akey=cred[1]) +@app.route("/intervals//sync", methods=["GET"]) +@login_required +def intervals_sync(username): + result = sync_intervals_workout_for_player(current_user.player_id) + flash(result['message']) + return redirect(url_for('intervals', username=current_user.username)) + + +@app.route("/trainingpeaks//", methods=["GET", "POST"]) +@login_required +def trainingpeaks(username): + if request.method == 'POST': + save_trainingpeaks_bridge_folder(current_user.player_id, request.form['bridge_folder']) + flash('TrainingPeaks bridge folder saved.') + return redirect(url_for('trainingpeaks', username=current_user.username)) + bridge_folder = load_trainingpeaks_bridge_folder(current_user.player_id) + return render_template( + "trainingpeaks.html", + username=current_user.username, + message=trainingpeaks_workouts.PARTNER_ACCESS_MESSAGE, + bridge_folder=bridge_folder, + ) + + +@app.route("/trainingpeaks//sync", methods=["GET"]) +@login_required +def trainingpeaks_sync(username): + result = sync_trainingpeaks_workout_for_player(current_user.player_id) + flash(result['message']) + return redirect(url_for('trainingpeaks', username=current_user.username)) + + @app.route("/user//") @login_required def user_home(username): @@ -2377,10 +2569,11 @@ def intervals_upload(player_id, activity): logger.info("intervals_credentials.bin missing, skip Intervals.icu activity update") return athlete_id, api_key = decrypt_credentials(intervals_credentials) + workout_metadata = load_intervals_workout_metadata(player_id) try: - from requests.auth import HTTPBasicAuth - url = 'http://intervals.icu/api/v1/athlete/%s/activities?name=%s' % (athlete_id, activity.name) - requests.post(url, files = {"file": BytesIO(activity.fit)}, auth = HTTPBasicAuth('API_KEY', api_key)) + result = intervals_workouts.upload_activity(athlete_id, api_key, activity, workout_metadata=workout_metadata) + if result.get('paired'): + clear_intervals_workout_metadata(player_id) except Exception as exc: logger.warning("Intervals.icu upload failed. No internet? %s" % repr(exc)) @@ -4446,6 +4639,14 @@ def start_zwift(): selected_climb = request.form['climb'] if selected_climb != 'CALENDAR': climb_override[request.remote_addr] = selected_climb + provider = resolve_workout_provider_for_player(current_user.player_id) + sync_result = None + if provider == 'intervals-icu': + sync_result = sync_intervals_workout_for_player(current_user.player_id) + elif provider == 'trainingpeaks': + sync_result = sync_trainingpeaks_workout_for_player(current_user.player_id) + if sync_result and sync_result['status'] in ('synced', 'error'): + flash(sync_result['message']) return redirect("/ride", 302)