Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ This MyTraL **minor** release brings:
- .

### Added
- .
- Added a Polar Flow integration: sync new training from Polar Flow
(flow.polar.com) via the official Polar AccessLink API (transaction model, no
duplicates), and backfill full history from the Polar "Download your data"
(GDPR) export ZIP. Gated behind the `MYTRAL_FF_POLAR_FLOW_IMPORT` feature flag.
See `POLAR_FLOW.md` for the design and setup steps. This is separate from the
existing Polar Precision Performance (.hrm/.pdd) import.
Comment on lines +11 to +16

### Fixed
- .
Expand Down
1 change: 1 addition & 0 deletions CREDITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Big thanks to 3rd party [FLOSS](https://en.wikipedia.org/wiki/Free_and_open-sour
* Jordan Russell and Martijn Laan ([Inno Setup](https://jrsoftware.org/isinfo.php) - Windows installer compiler)
* Hartmut Goebel, Giovanni Bajo, David Vierra, David Cortesi, and Martin Zibricky ([PyInstaller](https://pyinstaller.org/) - desktop installer)
* Holger Krekel ([pytest](https://pytest.org/) - Python testing that brings joy)
* Polar ([accesslink-example-python](https://github.com/polarofficial/accesslink-example-python) - reference for the Polar Flow AccessLink OAuth2 + transaction flow)

See [licenses](./licenses) folder for 3rd party content licensing details.

Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,4 @@ The filesystem store is the durable substrate for the JSON persistence implement

## External Integrations & Plugins

MyTraL integrates with external ecosystems through `integrations/*` and `plugins.py`, including Strava and other import/sync sources used by both direct import routes and background tasks. This component adapts third-party payloads to internal entities so downstream analytics and UI features remain consistent.
MyTraL integrates with external ecosystems through `integrations/*` and `plugins.py`, including Strava, Polar (both the legacy Precision Performance `.hrm`/`.pdd` files and Polar Flow via the AccessLink API plus the GDPR data export ZIP), and other import/sync sources used by both direct import routes and background tasks. This component adapts third-party payloads to internal entities so downstream analytics and UI features remain consistent. See `POLAR_FLOW.md` for the Polar Flow design.
1 change: 1 addition & 0 deletions mytral/blueprints/import_uri_space.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,7 @@ def tool_import():
import_gsheets_all_years_csv_form=forms.ImportGsheetsAllYearsCsvForm(),
import_mytral_json_form=forms.ImportMytralJsonForm(),
import_polar_hrm_form=forms.ImportPolarHrmForm(),
import_polar_flow_export_form=forms.ImportPolarFlowExportForm(),
import_strava_archive_form=forms.ImportStravaArchiveForm(),
import_fit_form=_build_fit_import_form(user_id),
import_gpx_form=_build_gpx_import_form(user_id),
Expand Down
347 changes: 347 additions & 0 deletions mytral/blueprints/polar_flow_uri_space.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,347 @@
# MyTraL: my trailing log
#
# Copyright (C) 2015-2026 Martin Dvorak <martin.dvorak@mindforger.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Polar Flow (AccessLink API + GDPR export) URI space."""

import datetime
import uuid

import flask

from mytral import app_config
from mytral import app_logger
from mytral import app_task_manager
from mytral import app_user_ds as ds
from mytral import ff
from mytral import forms
from mytral import persistences
from mytral import security
from mytral import tasks
from mytral.integrations import polar_flow
from mytral.routes import COOKIE_USER
from mytral.routes import flask_app
from mytral.tasks.do import polar_flow_export_import
from mytral.tasks.do import polar_flow_resync_all
from mytral.tasks.do import polar_flow_sync


def _guard(user_id: str | None):
"""Return a redirect response when access is not allowed, else ``None``."""
if not ff.can("POLAR_FLOW_IMPORT"):
return flask.redirect(flask.url_for("home"))
if not user_id:
return flask.redirect(flask.url_for("login"))
return None


def _build_polar_task_params(
user_id: str,
dataset_name: str,
import_recordings: bool = True,
) -> dict | None:
"""Build encrypted Polar Flow sync task params, or None if unauthenticated."""
user_profile = ds.profile(user_id)
if not polar_flow.is_authenticated(user_profile):
return None

# Polar access tokens do not expire and there is no refresh token, so the task
# only needs the token + user id (no client id/secret).
enc_key = app_config.encryption_key
return {
"user_id": user_id,
"dataset_name": dataset_name,
"import_recordings": import_recordings,
"access_token": security.encrypt(
user_profile.polar_flow_access_token or "", enc_key
),
"polar_user_id": user_profile.polar_flow_user_id or "",
}


def _submit(task_entity: tasks.TaskEntity, ok_msg: str, err_msg: str):
"""Submit a task and flash the outcome; return a redirect to the task list."""
try:
app_task_manager.executor.submit(task_entity)
flask.flash(ok_msg, "success")
except Exception as exc:
flask.flash(f"{err_msg}: {exc}", "danger")
return flask.redirect(flask.url_for("tasks_list"))


@flask_app.route("/polar/api-developer")
def polar_flow_api_developer():
"""Polar Flow developer dashboard - auth state and sync actions."""
user_id = flask.session.get(COOKIE_USER)
guard = _guard(user_id)
if guard:
return guard
user_profile = ds.profile(user_id)
return flask.render_template(
"polar-flow-developer.html",
ff=ff,
user_profile=user_profile,
is_authenticated=polar_flow.is_authenticated(user_profile),
)


@flask_app.route("/polar/api-secrets", methods=["GET", "POST"])
def polar_flow_api_secrets():
"""Set (or clear) the encrypted Polar Flow API client credentials."""
user_id = flask.session.get(COOKIE_USER)
guard = _guard(user_id)
if guard:
return guard
user_profile = ds.profile(user_id)
form = forms.PolarFlowSecretsForm()

if flask.request.method == "POST" and form.validate_on_submit():
user_profile.polar_flow_client_id = form.client_id.data.strip()
user_profile.polar_flow_client_secret = form.client_secret.data.strip()
ds.update_profile(user_profile)
Comment on lines +111 to +114
flask.flash("Polar Flow API secrets saved successfully.", "success")
return flask.redirect(flask.url_for("polar_flow_api_developer"))

if flask.request.method == "POST":
flask.flash("Polar Flow secrets error - check the form fields.", "error")

return flask.render_template(
"polar-flow-secrets.html",
user_profile=user_profile,
form=form,
)


@flask_app.route("/polar/api-secrets/reset", methods=["POST"])
def polar_flow_api_secrets_reset():
"""Clear the Polar Flow API client credentials."""
user_id = flask.session.get(COOKIE_USER)
guard = _guard(user_id)
if guard:
return guard
user_profile = ds.profile(user_id)
user_profile.polar_flow_client_id = ""
user_profile.polar_flow_client_secret = ""
ds.update_profile(user_profile)
flask.flash("Polar Flow API secrets cleared.", "success")
return flask.redirect(flask.url_for("polar_flow_api_developer"))


@flask_app.route("/polar/auth-start")
def polar_flow_auth_start():
"""Start the Polar Flow OAuth2 authentication."""
user_id = flask.session.get(COOKIE_USER)
guard = _guard(user_id)
if guard:
return guard
user_profile = ds.profile(user_id)

advice, msg = polar_flow.ask_mentor(user_profile)
if advice == polar_flow.AuthMentorAdvice.CONFIGURE:
flask.flash("Configure Polar Flow - set client ID and client secret", "info")
return flask.redirect(flask.url_for("polar_flow_api_secrets"))
if advice == polar_flow.AuthMentorAdvice.AUTHENTICATED:
return flask.redirect(flask.url_for("polar_flow_api_developer"))
if advice == polar_flow.AuthMentorAdvice.REGISTER_USER:
# token present but the user is not linked yet - just register, no re-OAuth
try:
polar_flow.register_user(
user_profile=user_profile, ds=ds, logger=app_logger
)
flask.flash("Polar Flow account linked.", "success")
except Exception as exc:
flask.flash(f"Polar Flow registration failed: {exc}", "danger")
return flask.redirect(flask.url_for("polar_flow_api_developer"))

# advice == AUTHENTICATE: no token yet - start the OAuth2 flow
flask.flash(msg, "info")
url = polar_flow.auth_get_auth_code_url(
user_profile=user_profile,
mytral_url=f"{flask.request.host_url}{polar_flow.URL_AUTH_CALLBACK}",
)
return flask.redirect(url)


@flask_app.route(f"/{polar_flow.URL_AUTH_CALLBACK}")
def polar_flow_auth_redirect():
"""Polar OAuth2 redirect - exchange the code for a token and link the user."""
user_id = flask.session.get(COOKIE_USER)
guard = _guard(user_id)
if guard:
return guard

auth_code = flask.request.args.get("code")
if not auth_code:
flask.flash("Polar Flow authentication error: code is missing", "error")
return flask.redirect(flask.url_for("polar_flow_api_developer"))

user_profile = ds.profile(user_id)
try:
polar_flow.auth_exchange_code_for_token(
user_profile=user_profile, code=str(auth_code), ds=ds, logger=app_logger
)
polar_flow.register_user(user_profile=user_profile, ds=ds, logger=app_logger)
flask.flash("Polar Flow authenticated successfully.", "success")
except Exception as exc:
flask.flash(f"Polar Flow authentication failed: {exc}", "danger")

return flask.redirect(flask.url_for("polar_flow_api_developer"))


@flask_app.route("/polar/auth-reset", methods=["POST"])
def polar_flow_auth_reset():
"""Reset the Polar Flow access token and user link."""
user_id = flask.session.get(COOKIE_USER)
guard = _guard(user_id)
if guard:
return guard
user_profile = ds.profile(user_id)
user_profile.polar_flow_access_token = ""
user_profile.polar_flow_user_id = ""
ds.update_profile(user_profile)
return flask.redirect(flask.url_for("polar_flow_api_developer"))


@flask_app.route("/polar/sync/new", methods=["POST"])
def polar_flow_sync_new():
"""Start an async task to sync new Polar Flow activities."""
user_id = flask.session.get(COOKIE_USER)
guard = _guard(user_id)
if guard:
return guard
user_profile = ds.profile(user_id)

task_params = _build_polar_task_params(
user_id=user_id,
dataset_name=user_profile.dataset_name,
import_recordings=flask.request.form.get("import_recordings", "1") == "1",
)
if task_params is None:
return flask.redirect(flask.url_for("polar_flow_auth_start"))

task = tasks.TaskEntity(
key=str(uuid.uuid4()),
user_id=str(user_id),
task_type=polar_flow_sync.PolarFlowSyncTask.TASK_TYPE,
status=tasks.TaskStatus.QUEUED,
created_at=datetime.datetime.now(),
started_at=None,
completed_at=None,
error_message=None,
error_type=None,
error_traceback=None,
progress=0,
parameters=task_params,
)
return _submit(
task,
ok_msg="Polar Flow sync started - check Tasks.",
err_msg="Could not start sync",
)


@flask_app.route("/polar/sync/resync-all", methods=["POST"])
def polar_flow_resync_all_route():
"""Start an async task to re-sync all Polar Flow activities (purge + re-pull)."""
user_id = flask.session.get(COOKIE_USER)
guard = _guard(user_id)
if guard:
return guard

if flask.request.form.get("purge_confirmed", "0") != "1":
flask.flash(
"Re-sync requires explicit confirmation via the confirmation button.",
"warning",
)
return flask.redirect(flask.url_for("polar_flow_api_developer"))

user_profile = ds.profile(user_id)
task_params = _build_polar_task_params(
user_id=user_id,
dataset_name=user_profile.dataset_name,
import_recordings=flask.request.form.get("import_recordings", "1") == "1",
)
if task_params is None:
return flask.redirect(flask.url_for("polar_flow_auth_start"))
task_params["purge_confirmed"] = True

task = tasks.TaskEntity(
key=str(uuid.uuid4()),
user_id=str(user_id),
task_type=polar_flow_resync_all.PolarFlowResyncAllTask.TASK_TYPE,
status=tasks.TaskStatus.QUEUED,
created_at=datetime.datetime.now(),
started_at=None,
completed_at=None,
error_message=None,
error_type=None,
error_traceback=None,
progress=0,
parameters=task_params,
)
return _submit(
task,
ok_msg="Polar Flow re-sync started - check Tasks.",
err_msg="Could not start re-sync",
)


@flask_app.route("/polar/import/export-zip", methods=["POST"])
def polar_flow_import_export_zip():
"""Upload a Polar Flow GDPR export ZIP and start the historical import task."""
user_id = flask.session.get(COOKIE_USER)
guard = _guard(user_id)
if guard:
return guard
user_id = str(user_id)
user_profile = ds.profile(user_id)

form = forms.ImportPolarFlowExportForm()
if not form.validate_on_submit():
for field_errors in form.errors.values():
for error in field_errors:
flask.flash(error, "warning")
return flask.redirect(flask.url_for("tool_import"))

# persist the uploaded ZIP into the user's work directory
user_work_dir = persistences.create_user_work(ds.user_dir(user_id=user_id))
zip_path = user_work_dir / f"{uuid.uuid4()}-polar-flow-export.zip"
form.archive.data.save(str(zip_path))

task = tasks.TaskEntity(
key=str(uuid.uuid4()),
user_id=user_id,
task_type=polar_flow_export_import.PolarFlowExportImportTask.TASK_TYPE,
status=tasks.TaskStatus.QUEUED,
created_at=datetime.datetime.now(),
started_at=None,
completed_at=None,
error_message=None,
error_type=None,
error_traceback=None,
progress=0,
parameters={
"user_id": user_id,
"dataset_name": user_profile.dataset_name,
"zip_path": str(zip_path),
"on_conflict": form.on_conflict.data,
},
)
return _submit(
task,
ok_msg="Polar Flow export import started - check Tasks.",
err_msg="Could not start export import",
)
Loading
Loading