diff --git a/rowers/integrations/__init__.py b/rowers/integrations/__init__.py index 343df09b..fff8ac50 100644 --- a/rowers/integrations/__init__.py +++ b/rowers/integrations/__init__.py @@ -2,3 +2,4 @@ from .c2 import C2Integration from .strava import StravaIntegration from .nk import NKIntegration from .sporttracks import SportTracksIntegration +from .rp3 import RP3Integration diff --git a/rowers/integrations/rp3.py b/rowers/integrations/rp3.py new file mode 100644 index 00000000..2900e6d3 --- /dev/null +++ b/rowers/integrations/rp3.py @@ -0,0 +1,252 @@ +from .integrations import SyncIntegration, NoTokenError +from rowers.models import User, Rower, Workout, TombStone + +from rowers.tasks import handle_rp3_async_workout +from rowsandall_app.settings import ( + RP3_CLIENT_ID, RP3_CLIENT_KEY, RP3_REDIRECT_URI, RP3_CLIENT_SECRET, + UPLOAD_SERVICE_URL, UPLOAD_SERVICE_SECRET +) + +from rowers.utils import myqueue, NoTokenError, dologging, uniqify +from django.utils import timezone +import requests +import pandas as pd +import arrow +import django_rq +queue = django_rq.get_queue('default') +queuelow = django_rq.get_queue('low') +queuehigh = django_rq.get_queue('high') + +from datetime import timedelta + +graphql_url = "https://rp3rowing-app.com/graphql" + + +class RP3Integration(SyncIntegration): + def __init__(self, *args, **kwargs): + super(RP3Integration, self).__init__(*args, **kwargs) + self.oauth_data = { + 'client_id': RP3_CLIENT_ID, + 'client_secret': RP3_CLIENT_SECRET, + 'redirect_uri': RP3_REDIRECT_URI, + 'autorization_uri': "https://rp3rowing-app.com/oauth/authorize?", + 'content_type': 'application/x-www-form-urlencoded', + # 'content_type': 'application/json', + 'tokenname': 'rp3token', + 'refreshtokenname': 'rp3refreshtoken', + 'expirydatename': 'rp3tokenexpirydate', + 'bearer_auth': False, + 'base_url': "https://rp3rowing-app.com/oauth/token", + 'scope': 'read,write', + } + + + def createworkoutdata(self, w, *args, **kwargs): + return None + + + def workout_export(self, workout, *args, **kwargs) -> str: + pass + + + def get_workouts(self, *args, **kwargs) -> int: + auth_token = self.open() + + r = self.rower + workouts_json = self.get_workout_list_json() + + workouts_list = pd.json_normalize(workouts_json['data']['workouts']) + + try: + rp3ids = workouts_list['id'].values + workouts_list.set_index('id',inpace=True) + except (KeyError, IndexError): + return 0 + + knownrp3ids = uniqify([ + w.uploadedtorp3 for w in Workout.objects.filter(user=rower) + ]) + + dologging('rp3_import.log',rp3ids) + + newids = [rp3id for rp3id in rp3ids if rp3id not in knownrp3ids] + + dologging('rp3_import.log',newids) + + for id in newids: + startdatetime = workouts_list.loc[id, 'executed_at_ios8601'] + dologging('rp3_import.log', startdatetime) + + _ = myqueue( + queuehigh, + handle_rp3_async_workout, + self.user.id, + auth_token, + id, + startdatetime, + 20, + {'timezone':self.rower.defaulttimezone} + ) + + return 1 + + def get_workout(self, id, *args, **kwargs) -> int: + startdatetime = kwargs.get('startdatetime', None) + if not startdatetime: + startdatetime = str(timezone.now()) + + auth_token = self.open() + _ = myqueue( + queuehigh, + handle_rp3_async_workout, + self.user.id, + auth_token, + id, + startdatetime, + 20, + timezone = self.rower.defaulttimezone + ) + + def get_workout_schema(self, *args, **kwargs) -> dict: + auth_token = self.open() + headers = {'Authorization': 'Bearer ' + auth_token} + get_schema = """{ + __type(name:"Workout") { + name + fields { + name + description + type { + name + kind + ofType { + name + kind + } + } + } + } + }""" + + response = requests.post( + url = graphql_url, + headers=headers, + json={'query':get_schema} + ) + return response.json() + + def get_workout_list_json(self, *args, **kwargs) -> dict: + auth_token = self.open() + r = self.rower + + headers = {'Authorization': 'Bearer ' + auth_token} + + get_workouts_list = """{ + workouts{ + id + executed_at_iso8601 + } + }""" + + response = requests.post( + url=graphql_url, + headers=headers, + json={'query': get_workouts_list} + ) + + if (response.status_code != 200): # pragma: no cover + raise NoTokenError("Need to authorize") + + return response.json() + + def get_workout_list(self, *args, **kwargs) -> list: + r = self.rower + + workouts_json = self.get_workout_list_json(*args, **kwargs) + + workouts_list = pd.json_normalize(workouts_json['data']['workouts']) + + knownrp3ids = uniqify([ + w.uploadedtorp3 for w in Workout.objects.filter(user=r) + ]) + + workouts = [] + + for key, data in workouts_list.iterrows(): + print(data) + try: + i = data['id'] + except KeyError: # pragma: no cover + i = 0 + if i in knownrp3ids: # pragma: no cover + nnn = '' + else: + nnn = 'NEW' + + try: + s = arrow.get(data['executed_at_iso8601']).isoformat() + except KeyError: # pragma: no cover + s = '' + + keys = ['id', 'distance', 'duration', 'starttime', + 'rowtype', 'source', 'name', 'new'] + values = [i, '', '', s, '', 'rp3', '', nnn] + + res = dict(zip(keys, values)) + + workouts.append(res) + + + return workouts + + + def make_authorization_url(self, *args, **kwargs) -> str: # pragma: no cover + return super(RP3Integration, self).make_authorization_url(*args, **krags) + + def get_token(self, code, *args, **kwargs) -> (str, int, str): + post_data = { + "client_id": RP3_CLIENT_KEY, + "grant_type": "authorization_code", + "code": code, + "redirect_uri": RP3_REDIRECT_URI, + "client_secret": RP3_CLIENT_SECRET, + } + + response = requests.post( + "https://rp3rowing-app.com/oauth/token", + data=post_data, verify=False, + ) + + try: + token_json = response.json() + thetoken = token_json['access_token'] + expires_in = token_json['expires_in'] + refresh_token = token_json['refresh_token'] + except KeyError: + thetoken = "" + expires_in = 0 + refresh_token = "" + + return thetoken, expires_in, refresh_token + + + def open(self, *args, **kwargs) -> str: + tokenexpirydate = self.user.rower.rp3tokenexpirydate + if tokenexpirydate is None: + raise NoTokenError("No Token") + if tokenexpirydate is not None and timezone.now()-timedelta(days=120)>tokenexpirydate: + self.rower.rp3tokenexpirydate = timezone.now()-timedelta(days=1) + self.rower.save() + raise NoTokenError("No Token") + return super(RP3Integration, self).open() + + + def token_refresh(self, *args, **kwargs) -> str: + return super(RP3Integration, self).token_refresh(*args, **kwargs) + + +# just as a quick test during development +u = User.objects.get(id=1) + +integration_1 = RP3Integration(u) + diff --git a/rowers/rp3stuff.py b/rowers/rp3stuff.py deleted file mode 100644 index b4eed2f6..00000000 --- a/rowers/rp3stuff.py +++ /dev/null @@ -1,267 +0,0 @@ -from celery import Celery, app -from rowers.rower_rules import is_workout_user -import time -from django_rq import job -from rowers.tasks import handle_rp3_async_workout -from rowsandall_app.settings import ( - C2_CLIENT_ID, C2_REDIRECT_URI, C2_CLIENT_SECRET, - STRAVA_CLIENT_ID, STRAVA_REDIRECT_URI, STRAVA_CLIENT_SECRET, - RP3_CLIENT_ID, RP3_CLIENT_KEY, RP3_REDIRECT_URI, RP3_CLIENT_SECRET, - UPLOAD_SERVICE_URL, UPLOAD_SERVICE_SECRET -) -from rowers.utils import myqueue, NoTokenError -# All the functionality needed to connect to Runkeeper -from rowers.imports import * - -# Python -import gzip - -from datetime import timedelta - -import base64 -from io import BytesIO - -from rowers.utils import dologging - -import django_rq -queue = django_rq.get_queue('default') -queuelow = django_rq.get_queue('low') -queuehigh = django_rq.get_queue('high') - - -oauth_data = { - 'client_id': RP3_CLIENT_ID, - 'client_secret': RP3_CLIENT_SECRET, - 'redirect_uri': RP3_REDIRECT_URI, - 'autorization_uri': "https://rp3rowing-app.com/oauth/authorize?", - 'content_type': 'application/x-www-form-urlencoded', - # 'content_type': 'application/json', - 'tokenname': 'rp3token', - 'refreshtokenname': 'rp3refreshtoken', - 'expirydatename': 'rp3tokenexpirydate', - 'bearer_auth': False, - 'base_url': "https://rp3rowing-app.com/oauth/token", - 'scope': 'read,write', -} - - -graphql_url = "https://rp3rowing-app.com/graphql" - - -# Checks if user has UnderArmour token, renews them if they are expired -def rp3_open(user): - tokenexpirydate = user.rower.rp3tokenexpirydate - if tokenexpirydate is None: - raise NoTokenError("No Token") - if tokenexpirydate is not None and timezone.now()-timedelta(days=120)>tokenexpirydate: - user.rower.rp3tokenexpirydate = timezone.now()-timedelta(days=1) - user.rower.save() - raise NoTokenError("No Token") - return imports_open(user, oauth_data) - -# Refresh ST token using refresh token - - -def do_refresh_token(refreshtoken): # pragma: no cover - return imports_do_refresh_token(refreshtoken, oauth_data) - -# Exchange access code for long-lived access token - - -def get_token(code): # pragma: no cover - post_data = { - "client_id": RP3_CLIENT_KEY, - "grant_type": "authorization_code", - "code": code, - "redirect_uri": RP3_REDIRECT_URI, - "client_secret": RP3_CLIENT_SECRET, - } - - response = requests.post( - "https://rp3rowing-app.com/oauth/token", - data=post_data, verify=False, - ) - - try: - token_json = response.json() - thetoken = token_json['access_token'] - expires_in = token_json['expires_in'] - refresh_token = token_json['refresh_token'] - except KeyError: - thetoken = 0 - expires_in = 0 - refresh_token = 0 - - return thetoken, expires_in, refresh_token - -# Make authorization URL including random string - - -def make_authorization_url(request): # pragma: no cover - return imports_make_authorization_url(oauth_data) - - -def get_rp3_workout_list(user): - auth_token = rp3_open(user) - - headers = {'Authorization': 'Bearer ' + auth_token} - - get_workouts_list = """{ - workouts{ - id - executed_at - } -}""" - - response = requests.post( - url=graphql_url, - headers=headers, - json={'query': get_workouts_list} - ) - - return response - - -def get_rp3_workouts(rower, do_async=True): # pragma: no cover - try: - auth_token = rp3_open(rower.user) - except NoTokenError: - return 0 - - res = get_rp3_workout_list(rower.user) - - if (res.status_code != 200): - return 0 - - s = '{d}'.format(d=res.json()) - dologging('rp3_import.log', s) - workouts_list = pd.json_normalize(res.json()['data']['workouts']) - try: - rp3ids = workouts_list['id'].values - workouts_list.set_index('id', inplace=True) - except (KeyError, IndexError): - return 0 - - knownrp3ids = uniqify([ - w.uploadedtorp3 for w in Workout.objects.filter(user=rower) - ]) - - dologging('rp3_import.log',rp3ids) - - newids = [rp3id for rp3id in rp3ids if rp3id not in knownrp3ids] - - dologging('rp3_import.log',newids) - - for id in newids: - startdatetime = workouts_list.loc[id, 'executed_at'] - dologging('rp3_import.log', startdatetime) - - _ = myqueue( - queuehigh, - handle_rp3_async_workout, - rower.user.id, - auth_token, - id, - startdatetime, - 20, - ) - - return 1 - - -def download_rp3_file(url, auth_token, filename): # pragma: no cover - headers = {'Authorization': 'Bearer ' + auth_token} - - res = requests.get(url, headers=headers) - - if res.status_code == 200: - with open(filename, 'wb') as f: - f.write(res.content) - - return res.status_code - - -def get_rp3_workout_token(workout_id, auth_token, waittime=3, max_attempts=20): # pragma: no cover - headers = {'Authorization': 'Bearer ' + auth_token} - - get_download_link = """{ - download(workout_id: """ + str(workout_id) + """, type:csv){ - id - status - link - } -}""" - - have_link = False - download_url = '' - counter = 0 - while not have_link: - response = requests.post( - url=graphql_url, - headers=headers, - json={'query': get_download_link} - ) - - if response.status_code != 200: - have_link = True - - workout_download_details = pd.json_normalize( - response.json()['data']['download']) - - if workout_download_details.iat[0, 1] == 'ready': - download_url = workout_download_details.iat[0, 2] - have_link = True - - counter += 1 - - if counter > max_attempts: - have_link = True - - time.sleep(waittime) - - return download_url - - -def get_rp3_workout_link(user, workout_id, waittime=3, max_attempts=20): # pragma: no cover - auth_token = rp3_open(user) - - return get_rp3_workout_token(workout_id, auth_token, waittime=waittime, max_attempts=max_attempts) - - -def get_rp3_workout(user, workout_id, startdatetime=None): # pragma: no cover - url = get_rp3_workout_link(user, workout_id) - filename = 'media/RP3Import_'+str(workout_id)+'.csv' - - auth_token = rp3_open(user) - - if not startdatetime: - startdatetime = str(timezone.now()) - - status_code = download_rp3_file(url, auth_token, filename) - - if status_code != 200: - return 0 - - userid = user.id - - uploadoptions = { - 'secret': UPLOAD_SERVICE_SECRET, - 'user': userid, - 'file': filename, - 'workouttype': 'dynamic', - 'boattype': '1x', - 'rp3id': workout_id, - 'startdatetime': startdatetime, - 'timezone': str(user.rower.defaulttimezone) - } - - session = requests.session() - newHeaders = {'Content-type': 'application/json', 'Accept': 'text/plain'} - session.headers.update(newHeaders) - - response = session.post(UPLOAD_SERVICE_URL, json=uploadoptions) - - if response.status_code != 200: - return 0 - - return response.json()['id'] diff --git a/rowers/tasks.py b/rowers/tasks.py index a327a511..5a311f1f 100644 --- a/rowers/tasks.py +++ b/rowers/tasks.py @@ -3158,6 +3158,9 @@ def handle_update_wps(rid, types, ids, mode, debug=False, **kwargs): @app.task def handle_rp3_async_workout(userid, rp3token, rp3id, startdatetime, max_attempts, debug=False, **kwargs): + + timezone = kwargs.get('timezone', 'UTC') + headers = {'Authorization': 'Bearer ' + rp3token} get_download_link = """{ @@ -3239,8 +3242,11 @@ def handle_rp3_async_workout(userid, rp3token, rp3id, startdatetime, max_attempt 'boattype': '1x', 'rp3id': int(rp3id), 'startdatetime': startdatetime, + 'timezone': timezone, } + print(uploadoptions) + session = requests.session() newHeaders = {'Content-type': 'application/json', 'Accept': 'text/plain'} session.headers.update(newHeaders) diff --git a/rowers/tests/test_imports.py b/rowers/tests/test_imports.py index 6a2f5ee2..0bac3505 100644 --- a/rowers/tests/test_imports.py +++ b/rowers/tests/test_imports.py @@ -971,8 +971,8 @@ class RP3Objects(DjangoTestCase): csvfilename=filename ) - @patch('rowers.rp3stuff.requests.get', side_effect=mocked_requests) - @patch('rowers.rp3stuff.requests.post', side_effect=mocked_requests) + @patch('rowers.integrations.rp3.requests.get', side_effect=mocked_requests) + @patch('rowers.integrations.rp3.requests.post', side_effect=mocked_requests) @patch('rowers.dataprep.getsmallrowdata_db', side_effect=mocked_getsmallrowdata_db) def test_rp3_import(self, mock_get, mockpost, mocked_getsmallrowdata_db): @@ -1002,7 +1002,7 @@ class RP3Objects(DjangoTestCase): res = tasks.handle_rp3_async_workout(userid,rp3token,rp3id,startdatetime,max_attempts) self.assertEqual(res,1) - @patch('rowers.rp3stuff.requests.post', side_effect=mocked_requests) + @patch('rowers.integrations.rp3.requests.post', side_effect=mocked_requests) def notest_rp3_callback(self, mock_post): response = self.c.get('/rp3_callback?code=absdef23&scope=read',follow=True) self.assertEqual(response.status_code, 200) diff --git a/rowers/tests/testdata/testdata.tcx.gz b/rowers/tests/testdata/testdata.tcx.gz index 7fe9f5fa..ef36d379 100644 Binary files a/rowers/tests/testdata/testdata.tcx.gz and b/rowers/tests/testdata/testdata.tcx.gz differ diff --git a/rowers/uploads.py b/rowers/uploads.py index 3a784473..f99501a4 100644 --- a/rowers/uploads.py +++ b/rowers/uploads.py @@ -235,9 +235,9 @@ def do_sync(w, options, quick=False): do_st_export = w.user.sporttracks_auto_export - if options['sporttracksid'] != 0 and options['sporttracksid'] != '': - w.uploadedtosporttracks = options['sporttracksid'] - + sporttracksid = options.get('sporttracksid','') + if sporttracksid != 0 and sporttracksid != '': + w.uploadedtosporttracks = sporttracksid w.save() do_st_export = False try: # pragma: no cover diff --git a/rowers/views/importviews.py b/rowers/views/importviews.py index 56dfc70b..8fa1bad4 100644 --- a/rowers/views/importviews.py +++ b/rowers/views/importviews.py @@ -725,11 +725,9 @@ def rower_process_rp3callback(request): # pragma: no cover url = reverse('rower_exportsettings_view') return HttpResponseRedirect(url) - res = rp3stuff.get_token(code) + rp3_integration = RP3Integration(request.user) + access_token, expires_in, refresh_token = rp3_integration.get_token(code) - access_token = res[0] - expires_in = res[1] - refresh_token = res[2] expirydatetime = timezone.now()+datetime.timedelta(seconds=expires_in) r = getrower(request.user) @@ -803,56 +801,39 @@ def rower_process_testcallback(request): # pragma: no cover @permission_required('rower.is_coach', fn=get_user_by_userid, raise_exception=True) def workout_rp3import_view(request, userid=0): r = getrequestrower(request, userid=userid) + rp3_integration = RP3Integration(request.user) try: - _ = rp3stuff.rp3_open(request.user) + _ = rp3_integration.open() except NoTokenError: # pragma: no cover url = reverse('rower_rp3_authorize') return HttpResponseRedirect(url) - res = rp3stuff.get_rp3_workout_list(request.user) + workouts = rp3_integration.get_workout_list() + datedict = {} + for workout in workouts: + datedict[workout['id']] = workout['starttime'] - if (res.status_code != 200): # pragma: no cover - if (res.status_code == 401): - r = getrower(request.user) - if (r.stravatoken == '') or (r.stravatoken is None): - s = "Token doesn't exist. Need to authorize" - return HttpResponseRedirect("/rowers/me/stravaauthorize/") - message = "Something went wrong in workout_rp3import_view" - messages.error(request, message) - url = reverse('workouts_view') - return HttpResponseRedirect(url) - workouts_list = pd.json_normalize(res.json()['data']['workouts']) + if request.method == "POST": + try: # pragma: no cover + tdict = dict(request.POST.lists()) + ids = tdict['workoutid'] + rp3ids = [int(id) for id in ids] - knownrp3ids = uniqify([ - w.uploadedtorp3 for w in Workout.objects.filter(user=r) - ]) - - workouts = [] - - for key, data in workouts_list.iterrows(): - try: - i = data['id'] + for rp3id in rp3ids: + rp3_integration.get_workout(rp3id,startdatetime=datedict[rp3id]) + # done, redirect to workouts list + messages.info( + request, + 'Your RP3 workouts will be imported in the background.' + ' It may take a few minutes before they appear.') + url = reverse('workouts_view') + return HttpResponseRedirect(url) except KeyError: # pragma: no cover - i = 0 - if i in knownrp3ids: # pragma: no cover - nnn = '' - else: - nnn = 'NEW' - - try: - s = data['executed_at'] - except KeyError: # pragma: no cover - s = '' - - keys = ['id', 'starttime', 'new'] - values = [i, s, nnn] - - res = dict(zip(keys, values)) - - workouts.append(res) + pass + breadcrumbs = [ { 'url': '/rowers/list-workouts/', @@ -864,13 +845,18 @@ def workout_rp3import_view(request, userid=0): }, ] - return render(request, 'rp3_list_import.html', + checknew = request.GET.get('selectallnew', False) + + + return render(request, 'list_import.html', { 'workouts': workouts, 'rower': r, 'active': 'nav-workouts', 'breadcrumbs': breadcrumbs, - 'teams': get_my_teams(request.user) + 'teams': get_my_teams(request.user), + 'integration': 'RP3', + 'checknew': checknew, }) # The page where you select which Strava workout to import @@ -1461,7 +1447,8 @@ def workout_getrp3workout_all(request): # pragma: no cover r = getrequestrower(request) - result = rp3stuff.get_rp3_workouts(r, do_async=True) + rp3_integration = RP3Integration(request.user) + result = rp3_integration.get_workouts() if result: messages.info( @@ -1582,21 +1569,12 @@ def workout_getrp3importview(request, externalid): r = getrequestrower(request) if r.user != request.user: # pragma: no cover messages.error( - request, 'You can only access your own workouts on the NK Logbook, not those of your athletes') + request, 'You can only access your own workouts on the RP3 Logbook, not those of your athletes') url = reverse('workout_rp3import_view', kwargs={ 'userid': request.user.id}) return HttpResponseRedirect(url) - token = rp3stuff.rp3_open(r.user) - startdatetime = request.GET.get('startdatetime') - - _ = myqueue(queuehigh, - handle_rp3_async_workout, - r.user.id, - token, - externalid, - startdatetime, - 20, - ) + rp3_integration = RP3Integration(request.user) + result = rp3_integration.get_workout(externalid) messages.info(request, 'The workout will be imported in the background') @@ -1684,8 +1662,11 @@ def workout_getimportview(request, externalid, source='c2', do_async=True): @login_required() def workout_getsporttracksworkout_all(request): st_integration = SportTracksIntegration(request.user) - _ = st_integration.get_workouts() - messages.info(request,"Your SportTracks workouts will be imported in the background") + try: + _ = st_integration.get_workouts() + messages.info(request,"Your SportTracks workouts will be imported in the background") + except NoTokenError: + messages.error(request,"You have to connect to SportTracks first") url = reverse('workouts_view') return HttpResponseRedirect(url) diff --git a/rowers/views/statements.py b/rowers/views/statements.py index 7d65e69b..f3f2def6 100644 --- a/rowers/views/statements.py +++ b/rowers/views/statements.py @@ -193,7 +193,7 @@ import sys import datetime import iso8601 import rowers.rojabo_stuff as rojabo_stuff -from rowers.rp3stuff import rp3_open + from rowers.tpstuff import tp_open from iso8601 import ParseError @@ -206,7 +206,7 @@ import rowers.polarstuff as polarstuff from rowers.integrations import * import rowers.tpstuff as tpstuff -import rowers.rp3stuff as rp3stuff + import rowers.ownapistuff as ownapistuff from rowers.ownapistuff import TEST_CLIENT_ID, TEST_CLIENT_SECRET, TEST_REDIRECT_URI from rowsandall_app.settings import (