diff --git a/cdn/static/web/launcher/intervals.html b/cdn/static/web/launcher/intervals.html index 286aaea..9f1ee13 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 %}
@@ -43,5 +44,19 @@ {% endif %} {% endwith %} + {% if sync_status and aid and akey %} +
+ +
+ {% endif %} {% endblock %} 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..8068655 --- /dev/null +++ b/cdn/static/web/launcher/trainingpeaks.html @@ -0,0 +1,51 @@ +{% extends "./layout.html" %} +{% block content %} +

TrainingPeaks

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

Logged in as {{ username }}

+ {% endif %} +
+
+ Back + Import workouts +
+
+
+
+
+
+
+ + +
+
+
+
+ +
+
+
+ {% with messages = get_flashed_messages() %} + {% if messages %} + + {% endif %} + {% endwith %} +
+
+
+
+
{{ message }}
+
+
+
Manual bridge mode: export TrainingPeaks workouts as .zwo files into a folder, save that folder path here, then click "Import workouts".
+
+
+
+
+{% endblock %} diff --git a/cdn/static/web/launcher/user_home.html b/cdn/static/web/launcher/user_home.html index e0d60e3..8e41a48 100644 --- a/cdn/static/web/launcher/user_home.html +++ b/cdn/static/web/launcher/user_home.html @@ -83,5 +83,21 @@ {% endif %} {% endwith %} + {% if workout_sync_status %} +
+ +
+ {% endif %} {% 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/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/workouts_manifest.py b/workouts_manifest.py new file mode 100644 index 0000000..473447f --- /dev/null +++ b/workouts_manifest.py @@ -0,0 +1,95 @@ +import hashlib +import os +import xml.etree.ElementTree as ET + + +MANIFEST_FILENAME = 'workouts.files' + + +def manifest_file(workouts_dir): + return os.path.join(workouts_dir, MANIFEST_FILENAME) + + +def parse_int(value): + try: + return int((value or '0').strip() or '0') + except ValueError: + return 0 + + +def file_checksum(content): + return (-sum(content)) % 256 + + +def file_guid(filename): + digest = hashlib.sha1(filename.encode('utf-8')).digest() + return (int.from_bytes(digest[:4], 'big') & 0x7fffffff) or 1 + + +def load_manifest(workouts_dir): + path = manifest_file(workouts_dir) + if not os.path.exists(path): + return [] + try: + root = ET.parse(path).getroot() + except ET.ParseError: + return [] + entries = [] + for node in root.findall('custom_file'): + name = (node.findtext('name') or '').strip() + if not name: + continue + entries.append({ + 'name': name, + 'time': parse_int(node.findtext('time')), + 'guid': parse_int(node.findtext('guid')), + 'checksum': parse_int(node.findtext('checksum')), + 'deleted': (node.findtext('deleted') or 'false').strip().lower() == 'true', + }) + return entries + + +def save_manifest(workouts_dir, entries): + os.makedirs(workouts_dir, exist_ok=True) + root = ET.Element('custom_file_directory') + for entry in sorted(entries, key=lambda item: item['name'].lower()): + node = ET.SubElement(root, 'custom_file') + ET.SubElement(node, 'name').text = entry['name'] + ET.SubElement(node, 'time').text = str(int(entry['time'])) + ET.SubElement(node, 'guid').text = str(int(entry['guid'])) + ET.SubElement(node, 'checksum').text = str(int(entry['checksum'])) + ET.SubElement(node, 'deleted').text = 'true' if entry.get('deleted') else 'false' + ET.SubElement(root, 'deleted_files') + tree = ET.ElementTree(root) + ET.indent(tree, space=' ') + path = manifest_file(workouts_dir) + tree.write(path, encoding='utf-8', xml_declaration=False) + with open(path, 'a', encoding='utf-8') as fd: + fd.write('\n') + return path + + +def upsert_manifest_entry(workouts_dir, filename, content, timestamp=None): + entries = [entry for entry in load_manifest(workouts_dir) if entry['name'] != filename] + timestamp = int(timestamp if timestamp is not None else os.path.getmtime(os.path.join(workouts_dir, filename))) + entry = { + 'name': filename, + 'time': timestamp, + 'guid': file_guid(filename), + 'checksum': file_checksum(content), + 'deleted': False, + } + entries.append(entry) + save_manifest(workouts_dir, entries) + return entry + + +def remove_prefixed_workouts(workouts_dir, prefixes): + entries = load_manifest(workouts_dir) + removed = 0 + for entry in entries: + if entry['name'].startswith(tuple(prefixes)): + entry['deleted'] = True + removed += 1 + save_manifest(workouts_dir, entries) + return removed diff --git a/zwift_offline.py b/zwift_offline.py index 28801a3..c7d055b 100644 --- a/zwift_offline.py +++ b/zwift_offline.py @@ -62,6 +62,10 @@ import fitness_pb2 import structured_events_pb2 import online_sync +import intervals_workouts +import trainingpeaks_workouts +import workout_state +import workouts_manifest logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO")) logger = logging.getLogger('zoffline') @@ -209,6 +213,7 @@ class AnonUser(User, AnonymousUserMixin, db.Model): first_name = "z" last_name = "offline" enable_ghosts = os.path.isfile(ENABLEGHOSTS_FILE) + is_admin = False def is_authenticated(self): return True @@ -860,6 +865,235 @@ 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 update_workouts_manifest(player_id, folder, filename, content): + workouts_manifest.upsert_manifest_entry(os.path.join(STORAGE_DIR, str(player_id), folder), filename, content) + timestamp = int(time.time()) + row = Zfile.query.filter_by(folder=folder, filename=workouts_manifest.MANIFEST_FILENAME, player_id=player_id).first() + if not row: + row = Zfile(folder=folder, filename=workouts_manifest.MANIFEST_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 managed_workout_prefixes(provider): + if provider == 'intervals-icu': + return {'intervals-icu-'} + if provider == 'trainingpeaks': + return {'trainingpeaks-'} + return set() + + +def clear_managed_workouts_for_provider(player_id, provider, clear_metadata=True): + prefixes = managed_workout_prefixes(provider) + for prefix in prefixes: + remove_player_zfiles_by_prefix(player_id, 'customworkouts', prefix) + if prefixes: + workouts_manifest.remove_prefixed_workouts(os.path.join(STORAGE_DIR, str(player_id), 'customworkouts'), prefixes) + if provider == 'intervals-icu' and clear_metadata: + clear_intervals_workout_metadata(player_id) + + +def current_workout_sync_status(player_id, provider=None): + provider = provider or resolve_workout_provider_for_player(player_id) + if not provider: + return None + metadata = load_workout_metadata(player_id, provider) + if not metadata: + return { + 'provider': provider, + 'metadata': None, + 'server_file_exists': False, + } + filename = metadata.get('filename') + server_file = os.path.join(STORAGE_DIR, str(player_id), 'customworkouts', filename) if filename else '' + return { + 'provider': provider, + 'metadata': metadata, + 'server_file_exists': bool(filename and os.path.exists(server_file)), + 'server_file': server_file, + } + + +def activate_workout_provider(player_id, provider): + save_active_workout_provider(player_id, provider) + if provider == 'intervals-icu': + clear_managed_workouts_for_provider(player_id, 'trainingpeaks', clear_metadata=False) + elif provider == 'trainingpeaks': + clear_managed_workouts_for_provider(player_id, 'intervals-icu') + + +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') + stored = {} + + def store_workout(filename, content, event): + clear_managed_workouts_for_provider(player_id, 'intervals-icu', clear_metadata=False) + stored['zfile'] = save_player_zfile(player_id, 'customworkouts', filename, content) + update_workouts_manifest(player_id, 'customworkouts', filename, content) + stored['metadata'] = 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_managed_workouts_for_provider(player_id, 'intervals-icu') + return { + **result, + 'sync_status': current_workout_sync_status(player_id, 'intervals-icu'), + 'message': 'No Intervals.icu workout found for today.', + } + if result['status'] == 'synced': + sync_status = current_workout_sync_status(player_id, 'intervals-icu') + return { + **result, + 'sync_status': sync_status, + } + 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') + clear_managed_workouts_for_provider(player_id, 'trainingpeaks', clear_metadata=False) + + def store_workout(filename, content, workout): + save_player_zfile(player_id, 'customworkouts', filename, content) + update_workouts_manifest(player_id, 'customworkouts', filename, content) + + try: + result = trainingpeaks_workouts.sync_exported_workouts(folder, store_workout) + if result['status'] == 'no_workout': + clear_managed_workouts_for_provider(player_id, 'trainingpeaks', clear_metadata=False) + return { + **result, + 'message': 'No .zwo workouts were found in the TrainingPeaks bridge folder.', + } + return result + 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): @@ -957,14 +1191,49 @@ def intervals(username): encrypt_credentials(file, (request.form['athlete_id'], request.form['api_key'])) return redirect(url_for('settings', username=current_user.username)) cred = decrypt_credentials(file) - return render_template("intervals.html", username=current_user.username, aid=cred[0], akey=cred[1]) + return render_template("intervals.html", username=current_user.username, aid=cred[0], akey=cred[1], + sync_status=current_workout_sync_status(current_user.player_id, 'intervals-icu')) + + +@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): + provider = resolve_workout_provider_for_player(current_user.player_id) + if provider == 'intervals-icu': + sync_intervals_workout_for_player(current_user.player_id) + elif provider == 'trainingpeaks': + sync_trainingpeaks_workout_for_player(current_user.player_id) return render_template("user_home.html", username=current_user.username, enable_ghosts=bool(current_user.enable_ghosts), climbs=CLIMBS, - online=get_online(), is_admin=current_user.is_admin, restarting=restarting, restarting_in_minutes=restarting_in_minutes) + online=get_online(), is_admin=current_user.is_admin, restarting=restarting, restarting_in_minutes=restarting_in_minutes, + active_workout_provider=provider, workout_sync_status=current_workout_sync_status(current_user.player_id)) def enqueue_player_update(player_id, wa_bytes): if not player_id in player_update_queue: @@ -2377,10 +2646,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 = 'https://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)) @@ -4345,8 +4615,7 @@ def launch_zwift(): if MULTIPLAYER: return redirect(url_for('login')) else: - return render_template("user_home.html", username=current_user.username, enable_ghosts=os.path.exists(ENABLEGHOSTS_FILE), online=get_online(), - climbs=CLIMBS, is_admin=False, restarting=restarting, restarting_in_minutes=restarting_in_minutes) + return redirect(url_for('user_home', username=current_user.username)) else: if MULTIPLAYER: return redirect("http://zwift/?code=zwift_refresh_token%s" % fake_refresh_token_with_session_cookie(request.cookies.get('remember_token')), 302)