From c50a30acc0e4553297b0ddb697d55a290ed6d038 Mon Sep 17 00:00:00 2001 From: Johannes la Poutre Date: Sat, 5 Jun 2021 21:44:56 +0200 Subject: [PATCH] feat(socialaccount): Add provider TrainingPeaks * Add TrainingPeaks provider * Add convenience method api_hostname * Add response.raise_for_status() * Rename TrainingPeaksOAuth2Adapter --- .../providers/trainingpeaks/__init__.py | 0 .../providers/trainingpeaks/provider.py | 46 ++++++++++ .../providers/trainingpeaks/tests.py | 85 +++++++++++++++++++ .../providers/trainingpeaks/urls.py | 6 ++ .../providers/trainingpeaks/views.py | 59 +++++++++++++ docs/installation.rst | 1 + docs/overview.rst | 2 + docs/providers.rst | 26 ++++++ test_settings.py | 1 + 9 files changed, 226 insertions(+) create mode 100644 allauth/socialaccount/providers/trainingpeaks/__init__.py create mode 100644 allauth/socialaccount/providers/trainingpeaks/provider.py create mode 100644 allauth/socialaccount/providers/trainingpeaks/tests.py create mode 100644 allauth/socialaccount/providers/trainingpeaks/urls.py create mode 100644 allauth/socialaccount/providers/trainingpeaks/views.py diff --git a/allauth/socialaccount/providers/trainingpeaks/__init__.py b/allauth/socialaccount/providers/trainingpeaks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/allauth/socialaccount/providers/trainingpeaks/provider.py b/allauth/socialaccount/providers/trainingpeaks/provider.py new file mode 100644 index 0000000000..5826a64721 --- /dev/null +++ b/allauth/socialaccount/providers/trainingpeaks/provider.py @@ -0,0 +1,46 @@ +from allauth.socialaccount.providers.base import ProviderAccount +from allauth.socialaccount.providers.oauth2.provider import OAuth2Provider + + +class TrainingPeaksAccount(ProviderAccount): + def get_profile_url(self): + return "https://app.trainingpeaks.com" + + def get_avatar_url(self): + return None + + def to_str(self): + name = self.account.extra_data.get("FirstName") + \ + " " + self.account.extra_data.get("LastName") + if name != " ": + return name + return super(TrainingPeaksAccount, self).to_str() + +class TrainingPeaksProvider(OAuth2Provider): + id = "trainingpeaks" + name = "TrainingPeaks" + account_class = TrainingPeaksAccount + + def extract_uid(self, data): + return data.get("Id") + + def extract_common_fields(self, data): + extra_common = super(TrainingPeaksProvider, self).extract_common_fields(data) + firstname = data.get("FirstName") + lastname = data.get("LastName") + # fallback username as there is actually no Username in response + username = firstname.strip().lower() + "." + lastname.strip().lower() + name = " ".join(part for part in (firstname, lastname) if part) + extra_common.update( + username=data.get("username", username), + email=data.get("Email"), + first_name=firstname, + last_name=lastname, + name=name.strip(), + ) + return extra_common + + def get_default_scope(self): + return ["athlete:profile"] + +provider_classes = [TrainingPeaksProvider] diff --git a/allauth/socialaccount/providers/trainingpeaks/tests.py b/allauth/socialaccount/providers/trainingpeaks/tests.py new file mode 100644 index 0000000000..5e60581ad5 --- /dev/null +++ b/allauth/socialaccount/providers/trainingpeaks/tests.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +""" + Run just this suite: + python manage.py test allauth.socialaccount.providers.trainingpeaks.tests.TrainingPeaksTests +""" +from __future__ import unicode_literals + +from collections import namedtuple + +from django.contrib.auth.models import User +from django.test.utils import override_settings + +from allauth.socialaccount.models import SocialAccount +from allauth.socialaccount.tests import OAuth2TestsMixin +from allauth.tests import MockedResponse, TestCase + +from .provider import TrainingPeaksProvider +from .views import TrainingPeaksOAuth2Adapter + + +class TrainingPeaksTests(OAuth2TestsMixin, TestCase): + provider_id = TrainingPeaksProvider.id + + def get_mocked_response(self): + return MockedResponse( + 200, + """{ + "Id": 123456, + "FirstName": "John", + "LastName": "Doe", + "Email": "user@example.com", + "DateOfBirth": "1986-02-01T00:00:00", + "CoachedBy": 987654, + "Weight": 87.5223617553711 + }""", + ) # noqa + + def get_login_response_json(self, with_refresh_token=True): + rtoken = "" + if with_refresh_token: + rtoken = ',"refresh_token": "testrf"' + return ( + """{ + "access_token" : "testac", + "token_type" : "bearer", + "expires_in" : 600, + "scope": "scopes granted" + %s }""" + % rtoken + ) + + def test_default_use_sandbox_uri(self): + adapter = TrainingPeaksOAuth2Adapter(None) + self.assertTrue('.sandbox.' in adapter.authorize_url) + self.assertTrue('.sandbox.' in adapter.access_token_url) + self.assertTrue('.sandbox.' in adapter.profile_url) + + @override_settings(SOCIALACCOUNT_PROVIDERS={ + 'trainingpeaks': { + 'USE_PRODUCTION': True + } + }) + def test_use_production_uri(self): + adapter = TrainingPeaksOAuth2Adapter(None) + self.assertFalse('.sandbox.' in adapter.authorize_url) + self.assertFalse('.sandbox.' in adapter.access_token_url) + self.assertFalse('.sandbox.' in adapter.profile_url) + + def test_scope_from_default(self): + Request = namedtuple('request', ['GET']) + mock_request = Request(GET={}) + scope = self.provider.get_scope(mock_request) + self.assertTrue('athlete:profile' in scope) + + @override_settings(SOCIALACCOUNT_PROVIDERS={ + 'trainingpeaks': { + 'SCOPE': ['athlete:profile', 'workouts', 'workouts:wod'] + } + }) + def test_scope_from_settings(self): + Request = namedtuple('request', ['GET']) + mock_request = Request(GET={}) + scope = self.provider.get_scope(mock_request) + for item in ('athlete:profile', 'workouts', 'workouts:wod'): + self.assertTrue(item in scope) diff --git a/allauth/socialaccount/providers/trainingpeaks/urls.py b/allauth/socialaccount/providers/trainingpeaks/urls.py new file mode 100644 index 0000000000..d524878e10 --- /dev/null +++ b/allauth/socialaccount/providers/trainingpeaks/urls.py @@ -0,0 +1,6 @@ +from allauth.socialaccount.providers.oauth2.urls import default_urlpatterns + +from .provider import TrainingPeaksProvider + + +urlpatterns = default_urlpatterns(TrainingPeaksProvider) diff --git a/allauth/socialaccount/providers/trainingpeaks/views.py b/allauth/socialaccount/providers/trainingpeaks/views.py new file mode 100644 index 0000000000..add81c8254 --- /dev/null +++ b/allauth/socialaccount/providers/trainingpeaks/views.py @@ -0,0 +1,59 @@ +import requests + +from allauth.socialaccount import app_settings +from allauth.socialaccount.providers.oauth2.views import ( + OAuth2Adapter, + OAuth2CallbackView, + OAuth2LoginView, +) + +from .provider import TrainingPeaksProvider + + +class TrainingPeaksOAuth2Adapter(OAuth2Adapter): + # https://github.com/TrainingPeaks/PartnersAPI/wiki/OAuth + provider_id = TrainingPeaksProvider.id + + def get_settings(self): + """ Provider settings """ + return app_settings.PROVIDERS.get(self.provider_id, {}) + + def get_hostname(self): + """ Return hostname depending on sandbox seting """ + settings = self.get_settings() + if (settings.get('USE_PRODUCTION')): + return 'trainingpeaks.com' + return 'sandbox.trainingpeaks.com' + + @property + def access_token_url(self): + return "https://oauth." + self.get_hostname() + "/oauth/token" + + @property + def authorize_url(self): + return "https://oauth." + self.get_hostname() + "/OAuth/Authorize" + + @property + def profile_url(self): + return "https://api." + self.get_hostname() + "/v1/athlete/profile" + + @property + def api_hostname(self): + """ Return https://api.hostname.tld """ + return "https://api." + self.get_hostname() + + # https://oauth.sandbox.trainingpeaks.com/oauth/deauthorize + + + scope_delimiter = " " + + def complete_login(self, request, app, token, **kwargs): + headers = {"Authorization": "Bearer {0}".format(token.token)} + response = requests.get(self.profile_url, headers=headers) + response.raise_for_status() + extra_data = response.json() + return self.get_provider().sociallogin_from_response(request, extra_data) + + +oauth2_login = OAuth2LoginView.adapter_view(TrainingPeaksOAuth2Adapter) +oauth2_callback = OAuth2CallbackView.adapter_view(TrainingPeaksOAuth2Adapter) diff --git a/docs/installation.rst b/docs/installation.rst index 3eda5e83a1..35dce6a4e2 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -131,6 +131,7 @@ settings.py (Important - Please note 'django.contrib.sites' is required as INSTA 'allauth.socialaccount.providers.strava', 'allauth.socialaccount.providers.stripe', 'allauth.socialaccount.providers.telegram', + 'allauth.socialaccount.providers.trainingpeaks', 'allauth.socialaccount.providers.trello', 'allauth.socialaccount.providers.tumblr', 'allauth.socialaccount.providers.twentythreeandme', diff --git a/docs/overview.rst b/docs/overview.rst index 024cd31cc0..e9593390d8 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -201,6 +201,8 @@ Supported Providers - Telegram +- TrainingPeaks (OAuth2) + - Trello (OAuth) - Tumblr (OAuth) diff --git a/docs/providers.rst b/docs/providers.rst index fdf9b1922a..3d07c44dae 100644 --- a/docs/providers.rst +++ b/docs/providers.rst @@ -1758,6 +1758,32 @@ See more in documentation https://stripe.com/docs/connect/standalone-accounts +TrainingPeaks +------------- + +You need to request an API Partnership to get your OAth credentials: + + https://api.trainingpeaks.com/request-access + +Make sure to request scope `athlete:profile` to be able to use OAuth +for user login (default if setting `SCOPE` is omitted). + +In development you should only use the sandbox services, which is the +default unless you set `USE_PRODUCTION` to `True`. + +.. code-block:: python + + SOCIALACCOUNT_PROVIDERS = { + 'trainingpeaks': { + 'SCOPE': ['athlete:profile'], + 'USE_PRODUCTION': False, + } + } + +API documentation: + + https://github.com/TrainingPeaks/PartnersAPI/wiki + Trello ------ diff --git a/test_settings.py b/test_settings.py index 59978b8ab4..7b1f544b6a 100644 --- a/test_settings.py +++ b/test_settings.py @@ -134,6 +134,7 @@ "allauth.socialaccount.providers.strava", "allauth.socialaccount.providers.stripe", "allauth.socialaccount.providers.telegram", + "allauth.socialaccount.providers.trainingpeaks", "allauth.socialaccount.providers.trello", "allauth.socialaccount.providers.tumblr", "allauth.socialaccount.providers.twentythreeandme",