diff --git a/rowers/integrations/__init__.py b/rowers/integrations/__init__.py index fff8ac50..06b659fd 100644 --- a/rowers/integrations/__init__.py +++ b/rowers/integrations/__init__.py @@ -3,3 +3,5 @@ from .strava import StravaIntegration from .nk import NKIntegration from .sporttracks import SportTracksIntegration from .rp3 import RP3Integration +from .trainingpeaks import TPIntegration + diff --git a/rowers/integrations/rp3.py b/rowers/integrations/rp3.py index 657176ef..fb1230c4 100644 --- a/rowers/integrations/rp3.py +++ b/rowers/integrations/rp3.py @@ -244,8 +244,4 @@ class RP3Integration(SyncIntegration): 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/integrations/trainingpeaks.py b/rowers/integrations/trainingpeaks.py new file mode 100644 index 00000000..d9cea185 --- /dev/null +++ b/rowers/integrations/trainingpeaks.py @@ -0,0 +1,134 @@ +from .integrations import SyncIntegration, NoTokenError +from rowers.models import User, Rower, Workout, TombStone + +import django_rq +queue = django_rq.get_queue('default') +queuelow = django_rq.get_queue('low') +queuehigh = django_rq.get_queue('high') + +from rowers.utils import myqueue, dologging, myqueue + +import requests + +from rowingdata import rowingdata + +from rowers.rower_rules import is_workout_user +import time +from django_rq import job + +from rowers.tasks import check_tp_workout_id, handle_workout_tp_upload + +from rowsandall_app.settings import ( + TP_CLIENT_ID, TP_CLIENT_SECRET, + TP_REDIRECT_URI, TP_CLIENT_KEY,TP_API_LOCATION, + TP_OAUTH_LOCATION, +) + +import gzip + +import base64 +from io import BytesIO + + +tpapilocation = TP_API_LOCATION + + + +class TPIntegration(SyncIntegration): + def __init__(self, *args, **kwargs): + super(TPIntegration, self).__init__(*args, **kwargs) + self.oauth_data = { + 'client_id': TP_CLIENT_ID, + 'client_secret': TP_CLIENT_SECRET, + 'redirect_uri': TP_REDIRECT_URI, + 'autorization_uri': "https://oauth.trainingpeaks.com/oauth/authorize?", + 'content_type': 'application/x-www-form-urlencoded', + 'tokenname': 'tptoken', + 'refreshtokenname': 'tprefreshtoken', + 'expirydatename': 'tptokenexpirydate', + 'bearer_auth': False, + 'base_url': "https://oauth.trainingpeaks.com/oauth/token", + 'scope': 'write', + } + + def createworkoutdata(self, w, *args, **kwargs): + filename = w.csvfilename + row = rowingdata(csvfile=filename) + tcxfilename = filename[:-4]+'.tcx' + try: + newnotes = w.notes+'\n from '+w.workoutsource+' via rowsandall.com' + except TypeError: + newnotes = 'from '+w.workoutsource+' via rowsandall.com' + + row.exporttotcx(tcxfilename, notes=newnotes) + + return tcxfilename + + + def workout_export(self, workout, *args, **kwargs) -> str: + thetoken = self.open() + tcxfilename = self.createworkoutdata(workout) + job = myqueue( + queue, + handle_workout_tp_upload, + workout, + thetoken, + tcxfilename + ) + return job.id + + + def get_workouts(self, *args, **kwargs) -> int: + raise NotImplementedError("not implemented") + + def get_workout(self, id) -> int: + raise NotImplementedError("not implemented") + + def get_workout_list(self, *args, **kwargs) -> list: + raise NotImplementedError("not implemented") + + def make_authorization_url(self, *args, **kwargs) -> str: # pragma: no cover + return super(TPIntegration, self).make_authorization_url(self, *args, **kwargs) + + def get_token(self, code, *args, **kwargs) -> (str, int, str): + # client_auth = requests.auth.HTTPBasicAuth(TP_CLIENT_KEY, TP_CLIENT_SECRET) + post_data = { + "client_id": TP_CLIENT_KEY, + "grant_type": "authorization_code", + "code": code, + "redirect_uri": TP_REDIRECT_URI, + "client_secret": TP_CLIENT_SECRET, + } + + response = requests.post( + TP_OAUTH_LOCATION+"/oauth/token/", + data=post_data, verify=False, + ) + + if response.status_code != 200: + raise NoTokenError + + try: + token_json = response.json() + thetoken = token_json['access_token'] + expires_in = token_json['expires_in'] + refresh_token = token_json['refresh_token'] + except KeyError: # pragma: no cover + thetoken = "" + expires_in = 0 + refresh_token = "" + + return thetoken, expires_in, refresh_token + + + def open(self, *args, **kwargs) -> str: + return super(TPIntegration, self).open(*args, **kwargs) + + def token_refresh(self, *args, **kwargs) -> str: + return super(TPIntegration, self).token_refresh(*args, **kwargs) + +# just as a quick test during development +u = User.objects.get(id=1) + +integration_1 = TPIntegration(u) + diff --git a/rowers/tasks.py b/rowers/tasks.py index 5430cf20..6418787a 100644 --- a/rowers/tasks.py +++ b/rowers/tasks.py @@ -47,6 +47,8 @@ import sys import json import traceback from time import strftime +import base64 +from io import BytesIO from scipy import optimize from scipy.signal import savgol_filter @@ -105,7 +107,8 @@ except KeyError: # pragma: no cover NK_API_LOCATION = CFG["nk_api_location"] TP_CLIENT_ID = CFG["tp_client_id"] TP_CLIENT_SECRET = CFG["tp_client_secret"] - +TP_API_LOCATION = CFG["tp_api_location"] +tpapilocation = TP_API_LOCATION from requests_oauthlib import OAuth1, OAuth1Session @@ -344,6 +347,46 @@ def handle_add_workouts_team(ws, t, debug=False, **kwargs): return 1 +def uploadactivity(access_token, filename, description='', + name='Rowsandall.com workout'): + + data_gz = BytesIO() + with open(filename, 'rb') as inF: + s = inF.read() + with gzip.GzipFile(fileobj=data_gz, mode="w") as gzf: + gzf.write(s) + + headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': 'Bearer %s' % access_token + } + + data = { + "UploadClient": "rowsandall", + "Filename": filename, + "SetWorkoutPublic": True, + "Title": name, + "Type": "rowing", + "Comment": description, + "Data": base64.b64encode(data_gz.getvalue()).decode("ascii") + } + + resp = requests.post(tpapilocation+"/v3/file", + data=json.dumps(data), + headers=headers, verify=False) + + if resp.status_code not in (200, 202): # pragma: no cover + dologging('tp_export.log',resp.status_code) + dologging('tp_export.log',resp.reason) + dologging('tp_export.log',json.dumps(data)) + return 0, resp.reason, resp.status_code, headers + else: + return 1, "ok", 200, resp.headers + + return 0, 0, 0, 0 # pragma: no cover + + @app.task def check_tp_workout_id(workout, location, attempts=5, debug=False, **kwargs): authorizationstring = str('Bearer ' + workout.user.tptoken) @@ -361,6 +404,37 @@ def check_tp_workout_id(workout, location, attempts=5, debug=False, **kwargs): return 1 +@app.task +def handle_workout_tp_upload(w, thetoken, tcxfilename, debug=False, **kwargs): + tpid = 0 + r = w.user + if not tcxfilename: + return 0 + + res, reason, status_code, headers = uploadactivity( + thetoken, tcxfilename, + name=w.name + ) + + if res == 0: + w.tpid = -1 + try: + os.remove(tcxfilename) + except WindowsError: + pass + + w.save() + return 0 + + w.uploadedtotp = res + tpid = res + w.save() + os.remove(tcxfilename) + + check_tp_workout_id(w,headers['Location']) + + return tpid + @app.task def instroke_static(w, metric, debug=False, **kwargs): f1 = w.csvfilename[6:-4] diff --git a/rowers/templates/menu_workout.html b/rowers/templates/menu_workout.html index bba56d91..6d8ff847 100644 --- a/rowers/templates/menu_workout.html +++ b/rowers/templates/menu_workout.html @@ -234,10 +234,6 @@ Connect to RP3 - {% else %} - - RP3 - {% endif %}
  • diff --git a/rowers/tests/test_imports.py b/rowers/tests/test_imports.py index 0bac3505..8ff6ab46 100644 --- a/rowers/tests/test_imports.py +++ b/rowers/tests/test_imports.py @@ -1373,15 +1373,15 @@ class TPObjects(DjangoTestCase): self.assertEqual(response.status_code, 200) - @patch('rowers.tpstuff.requests.post', side_effect=mocked_requests) + @patch('rowers.integrations.trainingpeaks.requests.post', side_effect=mocked_requests) def test_tp_token_refresh(self, mock_post): response = self.c.get('/rowers/me/tprefresh/',follow=True) self.assertEqual(response.status_code, 200) - @patch('rowers.tpstuff.requests.post', side_effect=mocked_requests) - @patch('rowers.tpstuff.requests.get', side_effect=mocked_requests) + @patch('rowers.integrations.trainingpeaks.requests.post', side_effect=mocked_requests) + @patch('rowers.integrations.trainingpeaks.requests.get', side_effect=mocked_requests) def test_tp_upload(self, mock_get, mock_post): url = '/rowers/workout/'+encoded1+'/tpuploadw/' diff --git a/rowers/tests/testdata/testdata.tcx.gz b/rowers/tests/testdata/testdata.tcx.gz index 3133a666..dcef8d75 100644 Binary files a/rowers/tests/testdata/testdata.tcx.gz and b/rowers/tests/testdata/testdata.tcx.gz differ diff --git a/rowers/tpstuff.py b/rowers/tpstuff.py deleted file mode 100644 index 25ef8616..00000000 --- a/rowers/tpstuff.py +++ /dev/null @@ -1,232 +0,0 @@ -from celery import Celery, app -from rowers.rower_rules import is_workout_user -import time -from django_rq import job -# All the functionality needed to connect to Runkeeper -from rowers.imports import * -from rowers.utils import dologging -from rowers.tasks import check_tp_workout_id - -import django_rq -queue = django_rq.get_queue('default') -queuelow = django_rq.get_queue('low') -queuehigh = django_rq.get_queue('low') - -from rowers.utils import myqueue - -# Python -import gzip - -import base64 -from io import BytesIO - - -from rowsandall_app.settings import ( - C2_CLIENT_ID, C2_REDIRECT_URI, C2_CLIENT_SECRET, - STRAVA_CLIENT_ID, STRAVA_REDIRECT_URI, STRAVA_CLIENT_SECRET, - TP_CLIENT_ID, TP_CLIENT_SECRET, - TP_REDIRECT_URI, TP_CLIENT_KEY,TP_API_LOCATION, - TP_OAUTH_LOCATION, -) - -tpapilocation = TP_API_LOCATION - -oauth_data = { - 'client_id': TP_CLIENT_ID, - 'client_secret': TP_CLIENT_SECRET, - 'redirect_uri': TP_REDIRECT_URI, - 'autorization_uri': "https://oauth.trainingpeaks.com/oauth/authorize?", - 'content_type': 'application/x-www-form-urlencoded', - # 'content_type': 'application/json', - 'tokenname': 'tptoken', - 'refreshtokenname': 'tprefreshtoken', - 'expirydatename': 'tptokenexpirydate', - 'bearer_auth': False, - 'base_url': "https://oauth.trainingpeaks.com/oauth/token", - 'scope': 'write', -} - - -# Checks if user has UnderArmour token, renews them if they are expired -def tp_open(user): - return imports_open(user, oauth_data) - -# Refresh ST token using refresh token - - -def do_refresh_token(refreshtoken): - return imports_do_refresh_token(refreshtoken, oauth_data) - -# Exchange access code for long-lived access token - -def get_token(code): - # client_auth = requests.auth.HTTPBasicAuth(TP_CLIENT_KEY, TP_CLIENT_SECRET) - post_data = { - "client_id": TP_CLIENT_KEY, - "grant_type": "authorization_code", - "code": code, - "redirect_uri": TP_REDIRECT_URI, - "client_secret": TP_CLIENT_SECRET, - } - - response = requests.post( - TP_OAUTH_LOCATION+"/oauth/token/", - data=post_data, verify=False, - ) - - if response.status_code != 200: - return 0,0,0 - - try: - token_json = response.json() - thetoken = token_json['access_token'] - expires_in = token_json['expires_in'] - refresh_token = token_json['refresh_token'] - except KeyError: # pragma: no cover - 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 getidfromresponse(response): # pragma: no cover - t = json.loads(response.text) - - links = t["_links"] - - id = links["self"][0]["id"] - - return int(id) - - -def createtpworkoutdata(w): - filename = w.csvfilename - row = rowingdata(csvfile=filename) - tcxfilename = filename[:-4]+'.tcx' - try: - newnotes = w.notes+'\n from '+w.workoutsource+' via rowsandall.com' - except TypeError: - newnotes = 'from '+w.workoutsource+' via rowsandall.com' - - row.exporttotcx(tcxfilename, notes=newnotes) - - return tcxfilename - - -def tp_check(access_token): # pragma: no cover - headers = { - "Content-Type": "application/json", - 'Accept': 'application/json', - 'authorization': 'Bearer %s' % access_token - } - - resp = requests.post(tpapilocation+"/v2/info/version", - headers=headers, verify=False) - - return resp - - -def uploadactivity(access_token, filename, description='', - name='Rowsandall.com workout'): - - data_gz = BytesIO() - with open(filename, 'rb') as inF: - s = inF.read() - with gzip.GzipFile(fileobj=data_gz, mode="w") as gzf: - gzf.write(s) - - headers = { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Authorization': 'Bearer %s' % access_token - } - - data = { - "UploadClient": "rowsandall", - "Filename": filename, - "SetWorkoutPublic": True, - "Title": name, - "Type": "rowing", - "Comment": description, - "Data": base64.b64encode(data_gz.getvalue()).decode("ascii") - } - - #resp = requests.post(tpapilocation+"/v2/file/synchronous", - # data=json.dumps(data), - # headers=headers, verify=False) - - resp = requests.post(tpapilocation+"/v3/file", - data=json.dumps(data), - headers=headers, verify=False) - - if resp.status_code not in (200, 202): # pragma: no cover - dologging('tp_export.log',resp.status_code) - dologging('tp_export.log',resp.reason) - dologging('tp_export.log',json.dumps(data)) - return 0, resp.reason, resp.status_code, headers - else: - return 1, "ok", 200, resp.headers - - return 0, 0, 0, 0 # pragma: no cover - - -def workout_tp_upload(user, w): # pragma: no cover - message = "Uploading to TrainingPeaks" - tpid = 0 - r = w.user - - thetoken = tp_open(r.user) - - # need some code if token doesn't refresh - - if (is_workout_user(user, w)): - tcxfile = createtpworkoutdata(w) - if tcxfile: - res, reason, status_code, headers = uploadactivity( - thetoken, tcxfile, - name=w.name - ) - if res == 0: - message = "Upload to TrainingPeaks failed with status code " + \ - str(status_code)+": "+reason - w.tpid = -1 - try: - os.remove(tcxfile) - except WindowsError: - pass - - return message, tpid - - else: # res != 0 - w.uploadedtotp = res - tpid = res - w.save() - os.remove(tcxfile) - - job = myqueue(queuelow, - check_tp_workout_id, - w, - headers['Location']) - - return 'Successfully synchronized to TrainingPeaks', tpid - - else: # no tcxfile - dologging('tp_export.log','Failed to create tcx file') - message = "Upload to TrainingPeaks failed" - w.uploadedtotp = -1 - tpid = -1 - w.save() - return message, tpid - else: # not allowed to upload - message = "You are not allowed to export this workout to TP" - tpid = 0 - return message, tpid - - return message, tpid diff --git a/rowers/uploads.py b/rowers/uploads.py index f99501a4..606d5a9e 100644 --- a/rowers/uploads.py +++ b/rowers/uploads.py @@ -1,7 +1,6 @@ from rowers.mytypes import workouttypes, boattypes, otwtypes, workoutsources, workouttypes_ordered from rowers.rower_rules import is_promember -import rowers.tpstuff as tpstuff from rowers.integrations import * from rowers.utils import ( @@ -270,9 +269,8 @@ def do_sync(w, options, quick=False): upload_to_st = False if do_tp_export: try: - _, id = tpstuff.workout_tp_upload( - w.user.user, w - ) + tp_integration = TPIntegration(w.user.user) + id = tp_integration.workout_export(w) dologging('tp_export.log', 'exported workout {wid} for user {uid}'.format( wid = w.id, diff --git a/rowers/views/importviews.py b/rowers/views/importviews.py index 06f51140..4193d681 100644 --- a/rowers/views/importviews.py +++ b/rowers/views/importviews.py @@ -23,12 +23,15 @@ def default(o): # pragma: no cover # Send workout to TP @permission_required('workout.change_workout', fn=get_workout_by_opaqueid, raise_exception=True) def workout_tp_upload_view(request, id=0): - + message = "" r = getrower(request.user) res = -1 + + tp_integration = TPIntegration(request.user) + try: - _ = tp_open(r.user) + _ = tp_integration.open() except NoTokenError: # pragma: no cover return HttpResponseRedirect("/rowers/me/tpauthorize/") @@ -36,38 +39,9 @@ def workout_tp_upload_view(request, id=0): w = get_workout_by_opaqueid(request, id) r = w.user - tcxfile = tpstuff.createtpworkoutdata(w) - if tcxfile: - res, reason, status_code, headers = tpstuff.uploadactivity( - r.tptoken, tcxfile, - name=w.name - ) - if res == 0: # pragma: no cover - message = "Upload to TrainingPeaks failed with status code " + \ - str(status_code)+": "+reason - try: - os.remove(tcxfile) - except WindowsError: - pass + jobid = tp_integration.workout_export(w) + messages.info(request,'Your workout will be exported to TrainingPeaks in the background') - messages.error(request, message) - - else: # res != 0 - w.uploadedtotp = res - w.save() - os.remove(tcxfile) - job = myqueue(queuelow, - check_tp_workout_id, - w, - headers['Location']) - - messages.info(request, 'Uploaded to TrainingPeaks') - - else: # pragma: no cover # no tcxfile - message = "Upload to TrainingPeaks failed" - w.uploadedtotp = -1 - w.save() - messages.error(request, message) url = reverse(r.defaultlandingpage, kwargs={ @@ -301,20 +275,8 @@ def rower_c2_token_refresh(request): @login_required() def rower_tp_token_refresh(request): r = getrower(request.user) - res = tpstuff.do_refresh_token( - r.tprefreshtoken, - ) - access_token = res[0] - expires_in = res[1] - refresh_token = res[2] - expirydatetime = timezone.now()+datetime.timedelta(seconds=expires_in) - - r = getrower(request.user) - r.tptoken = access_token - r.tptokenexpirydate = expirydatetime - r.tprefreshtoken = refresh_token - - r.save() + tp_integration = TPIntegration(request.user) + token = tp_integration.token_refresh() successmessage = "Tokens refreshed. Good to go" messages.info(request, successmessage) @@ -759,11 +721,8 @@ def rower_process_tpcallback(request): url = reverse('rower_exportsettings_view') return HttpResponseRedirect(url) - res = tpstuff.get_token(code) - - access_token = res[0] - expires_in = res[1] - refresh_token = res[2] + tp_integration = TPIntegration(request.user) + access_token, expires_in, refresh_token = tp_integration.get_token(code) expirydatetime = timezone.now()+datetime.timedelta(seconds=expires_in) r = getrower(request.user) @@ -1560,7 +1519,7 @@ importsources = { 'polar': polarstuff, 'ownapi': ownapistuff, 'sporttracks': SportTracksIntegration, - 'trainingpeaks': tpstuff, + 'trainingpeaks': TPIntegration, 'nk': NKIntegration, 'rp3':RP3Integration, } diff --git a/rowers/views/statements.py b/rowers/views/statements.py index f3f2def6..d0526ca8 100644 --- a/rowers/views/statements.py +++ b/rowers/views/statements.py @@ -194,7 +194,6 @@ import datetime import iso8601 import rowers.rojabo_stuff as rojabo_stuff -from rowers.tpstuff import tp_open from iso8601 import ParseError import rowers.rojabo_stuff as rojabo_stuff @@ -205,7 +204,6 @@ import rowers.polarstuff as polarstuff from rowers.integrations import * -import rowers.tpstuff as tpstuff import rowers.ownapistuff as ownapistuff from rowers.ownapistuff import TEST_CLIENT_ID, TEST_CLIENT_SECRET, TEST_REDIRECT_URI diff --git a/rowers/views/workoutviews.py b/rowers/views/workoutviews.py index 6e8d0a62..4a72c3e0 100644 --- a/rowers/views/workoutviews.py +++ b/rowers/views/workoutviews.py @@ -5636,9 +5636,6 @@ def workout_upload_view(request, except NoTokenError: id = 0 message = "Something went wrong with the Concept2 sync" - if id > 1: - messages.info(request, message) - else: messages.error(request, message) if (upload_to_strava): # pragma: no cover @@ -5648,9 +5645,6 @@ def workout_upload_view(request, except NoTokenError: id = 0 message = "Please connect to Strava first" - if id > 1: - messages.info(request, message) - else: messages.error(request, message) if (upload_to_st): # pragma: no cover @@ -5660,23 +5654,14 @@ def workout_upload_view(request, except NoTokenError: message = "Please connect to SportTracks first" id = 0 - if id > 1: - messages.info(request, message) - else: messages.error(request, message) if (upload_to_tp): # pragma: no cover + tp_integration = TPIntegration(request.user) try: - message, id = tpstuff.workout_tp_upload( - request.user, w - ) + id = tp_integration.workout_export(w) except NoTokenError: message = "Please connect to TrainingPeaks first" - id = 0 - - if id > 1: - messages.info(request, message) - else: messages.error(request, message) if int(registrationid) < 0: # pragma: no cover