diff --git a/rowers/models.py b/rowers/models.py index b573169c..49aeba2b 100644 --- a/rowers/models.py +++ b/rowers/models.py @@ -199,10 +199,17 @@ class Rower(models.Model): underarmourtokenexpirydate = models.DateTimeField(blank=True,null=True) underarmourrefreshtoken = models.CharField(default='',max_length=200, blank=True,null=True) + tptoken = models.CharField(default='',max_length=200,blank=True,null=True) + tptokenexpirydate = models.DateTimeField(blank=True,null=True) + tprefreshtoken = models.CharField(default='',max_length=200, + blank=True,null=True) + stravatoken = models.CharField(default='',max_length=200,blank=True,null=True) runkeepertoken = models.CharField(default='',max_length=200, blank=True,null=True) + + # runkeepertokenexpirydate = models.DateTimeField(blank=True,null=True) # runkeeperrefreshtoken = models.CharField(default='',max_length=200, # blank=True,null=True) @@ -365,6 +372,7 @@ class Workout(models.Model): uploadedtostrava = models.IntegerField(default=0) uploadedtosporttracks = models.IntegerField(default=0) uploadedtounderarmour = models.IntegerField(default=0) + uploadedtotp = models.IntegerField(default=0) uploadedtorunkeeper = models.IntegerField(default=0) # empower stuff diff --git a/rowers/tpstuff.py b/rowers/tpstuff.py new file mode 100644 index 00000000..c3ddf58e --- /dev/null +++ b/rowers/tpstuff.py @@ -0,0 +1,250 @@ +# All the functionality needed to connect to Runkeeper + +# Python +import oauth2 as oauth +import cgi +import requests +import requests.auth +import json +from django.utils import timezone +from datetime import datetime +import numpy as np +from dateutil import parser +import time +import math +import gzip +from math import sin,cos,atan2,sqrt +import os,sys +import urllib +import base64 +from io import BytesIO + +# Django +from django.shortcuts import render_to_response +from django.http import HttpResponseRedirect, HttpResponse,JsonResponse +from django.conf import settings +from django.contrib.auth import authenticate, login, logout +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required + +# Project +# from .models import Profile +from rowingdata import rowingdata +import pandas as pd +from rowers.models import Rower,Workout + +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, + ) + +tpapilocation = "https://api.sandbox.trainingpeaks.com" + +# Custom error class - to raise a NoTokenError +class TPNoTokenError(Exception): + def __init__(self,value): + self.value=value + + def __str__(self): + return repr(self.value) + + # Exponentially weighted moving average +# Used for data smoothing of the jagged data obtained by Strava +# See bitbucket issue 72 +def ewmovingaverage(interval,window_size): + # Experimental code using Exponential Weighted moving average + + try: + intervaldf = pd.DataFrame({'v':interval}) + idf_ewma1 = intervaldf.ewm(span=window_size) + idf_ewma2 = intervaldf[::-1].ewm(span=window_size) + + i_ewma1 = idf_ewma1.mean().ix[:,'v'] + i_ewma2 = idf_ewma2.mean().ix[:,'v'] + + interval2 = np.vstack((i_ewma1,i_ewma2[::-1])) + interval2 = np.mean( interval2, axis=0) # average + except ValueError: + interval2 = interval + + return interval2 + +from utils import geo_distance + + +# Custom exception handler, returns a 401 HTTP message +# with exception details in the json data +def custom_exception_handler(exc,message): + + response = { + "errors": [ + { + "code": str(exc), + "detail": message, + } + ] + } + + res = HttpResponse(message) + res.status_code = 401 + res.json = json.dumps(response) + + return res + +# Refresh ST token using refresh token +def do_refresh_token(refreshtoken,access_token): + client_auth = requests.auth.HTTPBasicAuth(TP_CLIENT_KEY, TP_CLIENT_SECRET) + post_data = {"grant_type": "refresh_token", + "client_secret": TP_CLIENT_SECRET, + "client_id":TP_CLIENT_KEY, + "refresh_token": refreshtoken, + } + headers = {'user-agent': 'sanderroosendaal', + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + 'authorization': 'Bearer %s' % access_token} + + url = "https://oauth.sandbox.trainingpeaks.com/oauth/token" + + response = requests.post(url, + data=post_data, + headers=headers) + + token_json = response.json() + thetoken = token_json['access_token'] + expires_in = token_json['expires_in'] + try: + refresh_token = token_json['refresh_token'] + except KeyError: + refresh_token = refreshtoken + + return [thetoken,expires_in,refresh_token] + +# 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, + } + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + } + + response = requests.post("https://oauth.sandbox.trainingpeaks.com/oauth/token", + data=post_data) + + print "Reason" + print response.reason + print response.content + + 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 + + return thetoken,expires_in,refresh_token + +# Make authorization URL including random string +def make_authorization_url(request): + # Generate a random string for the state parameter + # Save it for use later to prevent xsrf attacks + from uuid import uuid4 + state = str(uuid4()) + + params = {"client_id": TP_CLIENT_KEY, + "response_type": "code", + "redirect_uri": TP_REDIRECT_URI, + "scope": "write", + } + url = "https://oauth.sandbox.trainingpeaks.com/oauth/authorize?" +urllib.urlencode(params) + + return HttpResponseRedirect(url) + + + +def getidfromresponse(response): + t = json.loads(response.text) + + links = t["_links"] + + id = links["self"][0]["id"] + + return int(id) + +def createtpworkoutdata(w): + filename = w.csvfilename + try: + row = rowingdata(filename) + tcxfilename = filename[:-4]+'.tcx' + row.exporttotcx(tcxfilename,notes=w.notes) +# with file(tcxfilename,'rb') as inF: +# s = inF.read() +# with gzip.GzipFile(tcxfilename+'.gz','wb') as outF: +# outF.write(s) + return tcxfilename + except: + tcxfilename = 0 + + return tcxfilename + +def tp_check(access_token): + headers = { + "Content-Type": "application/json", + 'Accept': 'application/json', + 'authorization': 'Bearer %s' % access_token + } + + resp = requests.post(tpapilocation+"/v1/info/version", + headers=headers) + + return resp + +def uploadactivity(access_token,filename,description=''): + data_gz = BytesIO() + with file(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, + "Comment": description, + "Data": base64.b64encode(data_gz.getvalue()).decode("ascii") + } + + resp = requests.post(tpapilocation+"/v1/file", + data = json.dumps(data), + headers=headers) + + + if resp.status_code != 200: + print resp.status_code + print resp.reason + print "" + print headers + print "" + return 0 + else: + return resp.json()[0]["Id"] + + return 0 + + diff --git a/rowers/urls.py b/rowers/urls.py index 8fe5c55d..8454c943 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -238,6 +238,7 @@ urlpatterns = [ url(r'^workout/(\d+)/sporttracksuploadw/$',views.workout_sporttracks_upload_view), url(r'^workout/(\d+)/runkeeperuploadw/$',views.workout_runkeeper_upload_view), url(r'^workout/(\d+)/underarmouruploadw/$',views.workout_underarmour_upload_view), + url(r'^workout/(\d+)/tpuploadw/$',views.workout_tp_upload_view), url(r'^multi-compare$',views.multi_compare_view), url(r'^me/teams/c/(?P\w+.*)/s/(?P\w+.*)$',views.rower_teams_view), url(r'^me/teams/s/(?P\w+.*)$',views.rower_teams_view), @@ -273,9 +274,11 @@ urlpatterns = [ url(r'^me/stravaauthorize/$',views.rower_strava_authorize), url(r'^me/sporttracksauthorize/$',views.rower_sporttracks_authorize), url(r'^me/underarmourauthorize/$',views.rower_underarmour_authorize), + url(r'^me/tpauthorize/$',views.rower_tp_authorize), url(r'^me/runkeeperauthorize/$',views.rower_runkeeper_authorize), url(r'^me/sporttracksrefresh/$',views.rower_sporttracks_token_refresh), url(r'^me/underarmourrefresh/$',views.rower_underarmour_token_refresh), + url(r'^me/tprefresh/$',views.rower_tp_token_refresh), url(r'^me/c2refresh/$',views.rower_c2_token_refresh), url(r'^me/favoritecharts/$',views.rower_favoritecharts_view), url(r'^email/send/$', views.sendmail), diff --git a/rowers/views.py b/rowers/views.py index 57547fd6..83e4c269 100644 --- a/rowers/views.py +++ b/rowers/views.py @@ -3,6 +3,7 @@ import timestring import zipfile import operator import warnings +import urllib from numbers import Number from django.views.generic.base import TemplateView from django.db.models import Q @@ -53,10 +54,12 @@ import c2stuff from c2stuff import C2NoTokenError from runkeeperstuff import RunKeeperNoTokenError from sporttracksstuff import SportTracksNoTokenError +from tpstuff import TPNoTokenError from iso8601 import ParseError import stravastuff import sporttracksstuff import underarmourstuff +import tpstuff import runkeeperstuff import ownapistuff from ownapistuff import TEST_CLIENT_ID, TEST_CLIENT_SECRET, TEST_REDIRECT_URI @@ -68,6 +71,7 @@ from rowsandall_app.settings import ( UNDERARMOUR_CLIENT_ID, UNDERARMOUR_REDIRECT_URI, UNDERARMOUR_CLIENT_SECRET,UNDERARMOUR_CLIENT_KEY, RUNKEEPER_CLIENT_ID,RUNKEEPER_REDIRECT_URI,RUNKEEPER_CLIENT_SECRET, + TP_CLIENT_ID,TP_REDIRECT_URI,TP_CLIENT_KEY,TP_CLIENT_SECRET, ) import requests @@ -1075,6 +1079,20 @@ def underarmour_open(user): return thetoken +# Checks if user has UnderArmour token, renews them if they are expired +def tp_open(user): + r = Rower.objects.get(user=user) + if (r.tptoken == '') or (r.tptoken is None): + s = "Token doesn't exist. Need to authorize" + raise TPNoTokenError("User has no token") + else: + if (timezone.now()>r.tptokenexpirydate): + thetoken = tpstuff.rower_tp_token_refresh(user) + else: + thetoken = r.tptoken + + return thetoken + # Checks if user has SportTracks token, renews them if they are expired def runkeeper_open(user): r = Rower.objects.get(user=user) @@ -1190,6 +1208,73 @@ def workout_csvemail_view(request,id=0): return response +# Send workout to TP +@login_required() +def workout_tp_upload_view(request,id=0): + message = "" + r = Rower.objects.get(user=request.user) + res = -1 + try: + thetoken = tp_open(r.user) + except TPNoTokenError: + return HttpResponseRedirect("/rowers/me/tpauthorize/") + + + # ready to upload. Hurray + try: + w = Workout.objects.get(id=id) + r = w.user + except Workout.DoesNotExist: + raise Http404("Workout doesn't exist") + if (checkworkoutuser(request.user,w)): + tcxfile = tpstuff.createtpworkoutdata(w) + if tcxfile: + res = tpstuff.uploadactivity(r.tptoken,tcxfile, + description=w.notes) + if res == 0: + message = "Upload to TrainingPeaks failed" + w.uploadedtotp = -1 + w.save() + try: + os.remove(tcxfile) + except WindowsError: + pass + url = reverse(workout_export_view, + kwargs = { + 'id':str(w.id), + 'message':message, + }) + + else: # res != 0 + w.uploadedtotp = res + w.save() + os.remove(tcxfile) + url = reverse(workout_export_view, + kwargs = { + 'id':str(w.id), + 'successmessage':'Uploaded to TP', + }) + + else: # no tcxfile + message = "Upload to TrainingPeaks failed" + w.uploadedtotp = -1 + w.save() + url = reverse(workout_export_view, + kwargs = { + 'id':str(w.id), + 'message':message, + }) + else: # not allowed to upload + message = "You are not allowed to export this workout to TP" + url = reverse(workout_export_view, + kwargs = { + 'id':str(w.id), + 'message':message, + }) + + return HttpResponseRedirect(url) + + # Send workout to Strava # abundance of error logging here because there were/are some bugs @login_required() @@ -1319,7 +1404,6 @@ def workout_c2_upload_view(request,id=0): headers = {'Authorization': authorizationstring, 'user-agent': 'sanderroosendaal', 'Content-Type': 'application/json'} - import urllib try: url = "https://log.concept2.com/api/users/%s/results" % (c2userid) response = requests.post(url,headers=headers,data=json.dumps(data)) @@ -1404,7 +1488,6 @@ def workout_runkeeper_upload_view(request,id=0): 'Content-Type': 'application/vnd.com.runkeeper.NewFitnessActivity+json', 'Content-Length':'nnn'} - import urllib url = "https://api.runkeeper.com/fitnessActivities" response = requests.post(url,headers=headers,data=json.dumps(data)) @@ -1470,7 +1553,6 @@ def workout_underarmour_upload_view(request,id=0): 'Content-Type': 'application/json', } - import urllib url = "https://api.ua.com/v7.1/workout/" response = requests.post(url,headers=headers,data=json.dumps(data)) @@ -1533,7 +1615,6 @@ def workout_sporttracks_upload_view(request,id=0): 'user-agent': 'sanderroosendaal', 'Content-Type': 'application/json'} - import urllib url = "https://api.sporttracks.mobi/api/v2/fitnessActivities.json" response = requests.post(url,headers=headers,data=json.dumps(data)) @@ -1575,7 +1656,6 @@ def rower_c2_authorize(request): params = {"client_id": C2_CLIENT_ID, "response_type": "code", "redirect_uri": C2_REDIRECT_URI} - import urllib url = "http://log.concept2.com/oauth/authorize?"+ urllib.urlencode(params) url += "&scope="+scope return HttpResponseRedirect(url) @@ -1593,7 +1673,6 @@ def rower_strava_authorize(request): "redirect_uri": STRAVA_REDIRECT_URI, "scope": "write"} - import urllib url = "https://www.strava.com/oauth/authorize?"+ urllib.urlencode(params) return HttpResponseRedirect(url) @@ -1611,7 +1690,6 @@ def rower_runkeeper_authorize(request): "state": state, "redirect_uri": RUNKEEPER_REDIRECT_URI} - import urllib url = "https://runkeeper.com/apps/authorize?"+ urllib.urlencode(params) @@ -1630,7 +1708,6 @@ def rower_sporttracks_authorize(request): "state": state, "redirect_uri": SPORTTRACKS_REDIRECT_URI} - import urllib url = "https://api.sporttracks.mobi/oauth2/authorize?"+ urllib.urlencode(params) @@ -1653,6 +1730,23 @@ def rower_underarmour_authorize(request): return HttpResponseRedirect(url) +# Underarmour Authorization +@login_required() +def rower_tp_authorize(request): + # Generate a random string for the state parameter + # Save it for use later to prevent xsrf attacks + from uuid import uuid4 + state = str(uuid4()) + params = {"client_id": TP_CLIENT_KEY, + "response_type": "code", + "redirect_uri": TP_REDIRECT_URI, + "scope": "write", + } + url = "https://oauth.sandbox.trainingpeaks.com/oauth/authorize/?" +urllib.urlencode(params) + + return HttpResponseRedirect(url) + + # Concept2 token refresh. URL for manual refresh. Not visible to users @login_required() def rower_c2_token_refresh(request): @@ -1702,6 +1796,30 @@ def rower_underarmour_token_refresh(request): return imports_view(request,successmessage=successmessage) +# TrainingPeaks token refresh. URL for manual refresh. Not visible to users +@login_required() +def rower_tp_token_refresh(request): + r = Rower.objects.get(user=request.user) + res = tpstuff.do_refresh_token( + r.tprefreshtoken, + r.tptoken + ) + access_token = res[0] + expires_in = res[1] + refresh_token = res[2] + expirydatetime = timezone.now()+datetime.timedelta(seconds=expires_in) + + r = Rower.objects.get(user=request.user) + r.tptoken = access_token + r.tptokenexpirydate = expirydatetime + r.tprefreshtoken = refresh_token + + r.save() + + successmessage = "Tokens refreshed. Good to go" + return imports_view(request,successmessage=successmessage) + + # SportTracks token refresh. URL for manual refresh. Not visible to users @login_required() def rower_sporttracks_token_refresh(request): @@ -1863,6 +1981,28 @@ def rower_process_underarmourcallback(request): successmessage = "Tokens stored. Good to go" return imports_view(request,successmessage=successmessage) +# Process TrainingPeaks callback +@login_required() +def rower_process_tpcallback(request): + code = request.GET['code'] + res = tpstuff.get_token(code) + + + access_token = res[0] + expires_in = res[1] + refresh_token = res[2] + expirydatetime = timezone.now()+datetime.timedelta(seconds=expires_in) + + r = Rower.objects.get(user=request.user) + r.tptoken = access_token + r.tptokenexpirydate = expirydatetime + r.tprefreshtoken = refresh_token + + r.save() + + successmessage = "Tokens stored. Good to go" + return imports_view(request,successmessage=successmessage) + # Process Own API callback - for API testing purposes @login_required() def rower_process_testcallback(request): @@ -5720,7 +5860,7 @@ def workout_upload_view(request,message="", headers = {'Authorization': authorizationstring, 'user-agent': 'sanderroosendaal', 'Content-Type': 'application/json'} - import urllib + url = "https://log.concept2.com/api/users/%s/results" % (c2userid) response = requests.post(url,headers=headers,data=json.dumps(data)) diff --git a/rowsandall_app/settings.py b/rowsandall_app/settings.py index 42ada514..b02cd593 100644 --- a/rowsandall_app/settings.py +++ b/rowsandall_app/settings.py @@ -242,6 +242,12 @@ UNDERARMOUR_CLIENT_KEY = CFG['underarmour_client_key'] UNDERARMOUR_REDIRECT_URI = "http://rowsandall.com/underarmour_callback" #UNDERARMOUR_REDIRECT_URI = "http://localhost:8000/underarmour_callback" +# TrainingPeaks +TP_CLIENT_ID = CFG["tp_client_id"] +TP_CLIENT_SECRET = CFG["tp_client_secret"] +TP_REDIRECT_URI = "http://localhost:8000/tp_callback" +TP_CLIENT_KEY = TP_CLIENT_ID + # RQ stuff RQ_QUEUES = { diff --git a/rowsandall_app/urls.py b/rowsandall_app/urls.py index 26734641..073f0c69 100644 --- a/rowsandall_app/urls.py +++ b/rowsandall_app/urls.py @@ -57,6 +57,7 @@ urlpatterns += [ url(r'^sporttracks\_callback',rowersviews.rower_process_sporttrackscallback), url(r'^underarmour\_callback',rowersviews.rower_process_underarmourcallback), url(r'^runkeeper\_callback',rowersviews.rower_process_runkeepercallback), + url(r'^tp\_callback',rowersviews.rower_process_tpcallback), url(r'^twitter\_callback',rowersviews.rower_process_twittercallback), url(r'^i18n/', include('django.conf.urls.i18n')), ]