feat: add workout provider sync integrations

This commit is contained in:
sumulige
2026-04-22 13:00:26 +08:00
parent f7c0aef851
commit f0418ee337
11 changed files with 830 additions and 3 deletions
+2
View File
@@ -5,3 +5,5 @@ __pycache__/
build/
dist/
logs/
.venv/
scripts/strava_token.txt
+1
View File
@@ -8,6 +8,7 @@
<div class="col-md-12">
<a href="{{ url_for('settings', username=username) }}" class="btn btn-sm btn-secondary">Back</a>
{% if aid or akey %}
<a href="{{ url_for('intervals_sync', username=username) }}" class="btn btn-sm btn-secondary">Sync today's workout</a>
<a href="/delete/intervals_credentials.bin" class="btn btn-sm btn-danger">Remove credentials</a>
{% endif %}
</div>
+1
View File
@@ -14,6 +14,7 @@
<a href="{{ url_for('profile', username=username) }}" class="btn btn-sm btn-secondary">Zwift</a>
<a href="{{ url_for('strava', username=username) }}" class="btn btn-sm btn-secondary">Strava</a>
<a href="{{ url_for('intervals', username=username) }}" class="btn btn-sm btn-secondary">Intervals</a>
<a href="{{ url_for('trainingpeaks', username=username) }}" class="btn btn-sm btn-secondary">TrainingPeaks</a>
</div>
</div>
{% if files %}
@@ -0,0 +1,49 @@
{% extends "./layout.html" %}
{% block content %}
<h1><div class="text-shadow">TrainingPeaks</div></h1>
{% if username != "zoffline" %}
<h4 class="text-shadow">Logged in as {{ username }}</h4>
{% endif %}
<div class="row">
<div class="col-md-12">
<a href="{{ url_for('settings', username=username) }}" class="btn btn-sm btn-secondary">Back</a>
<a href="{{ url_for('trainingpeaks_sync', username=username) }}" class="btn btn-sm btn-secondary">Import workouts</a>
</div>
</div>
<div class="row">
<div class="col-sm-8 col-md-6 top-buffer">
<div class="list-group">
<div class="list-group-item py-2">
<div class="text-shadow">{{ message }}</div>
</div>
<div class="list-group-item py-2">
<div class="text-shadow">Manual bridge mode: export TrainingPeaks workouts as .zwo files into a local folder, save that folder path here, then click "Import workouts".</div>
</div>
</div>
<form id="trainingpeaks" action="{{ url_for('trainingpeaks', username=username) }}" method="post" class="top-buffer">
<div class="row">
<div class="col-md-12">
<label class="col-form-label col-form-label-sm text-shadow">Bridge folder</label>
<input type="text" id="bridge_folder" name="bridge_folder" value="{{ bridge_folder }}" class="form-control form-control-sm" placeholder="/path/to/TrainingPeaks/exports">
</div>
</div>
<div class="row">
<div class="col-md-12 top-buffer">
<input type="submit" value="Save bridge folder" class="btn btn-sm btn-light">
</div>
</div>
</form>
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul class="list-group top-buffer">
{% for message in messages %}
<li class="list-group-item py-2">
<div class="text-shadow">{{ message }}</div>
</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
</div>
</div>
{% endblock %}
+135
View File
@@ -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)}",
}
+152
View File
@@ -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"<workout_file />"),
])
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"<workout_file />")
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()
+55
View File
@@ -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'<workout_file id="1"/>')
with open(os.path.join(tmp, 'Tempo Ride.zwo'), 'wb') as fd:
fd.write(b'<workout_file id="2"/>')
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()
+73
View File
@@ -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()
+78
View File
@@ -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,
}
+80
View File
@@ -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
+204 -3
View File
@@ -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/<username>/", 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/<username>/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/<username>/", 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/<username>/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/<username>/")
@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)