diff --git a/rowers/models.py b/rowers/models.py index c6603e37..6c581794 100644 --- a/rowers/models.py +++ b/rowers/models.py @@ -196,6 +196,12 @@ class Rower(models.Model): sporttracksrefreshtoken = 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) # Plan plans = ( @@ -400,6 +406,7 @@ class Workout(models.Model): maxhr = models.IntegerField(blank=True,null=True) uploadedtostrava = models.IntegerField(default=0) uploadedtosporttracks = models.IntegerField(default=0) + uploadedtorunkeeper = models.IntegerField(default=0) # empower stuff inboard = models.FloatField(default=0.88) diff --git a/rowers/runkeeperstuff.py b/rowers/runkeeperstuff.py new file mode 100644 index 00000000..48b82a31 --- /dev/null +++ b/rowers/runkeeperstuff.py @@ -0,0 +1,188 @@ +# 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 +from math import sin,cos,atan2,sqrt +import os,sys + +# 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, + RUNKEEPER_CLIENT_ID, RUNKEEPER_CLIENT_SECRET,RUNKEEPER_REDIRECT_URI, + ) + +# 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 + +# Exchange access code for long-lived access token +def get_token(code): + client_auth = requests.auth.HTTPBasicAuth(RUNKEEPER_CLIENT_ID, RUNKEEPER_CLIENT_SECRET) + post_data = {"grant_type": "authorization_code", + "code": code, + "redirect_uri": RUNKEEPER_REDIRECT_URI, + "client_secret": RUNKEEPER_CLIENT_SECRET, + "client_id":RUNKEEPER_CLIENT_ID, + } + headers = {'user-agent': 'sanderroosendaal'} + response = requests.post("https://runkeeper.com/apps/token", + data=post_data, + headers=headers) + try: + token_json = response.json() + thetoken = token_json['access_token'] + except KeyError: + thetoken = 0 + + return thetoken + +# 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": RUNKEEPER_CLIENT_ID, + "response_type": "code", + "redirect_uri": RUNKEEPER_REDIRECT_URI, + } + import urllib + url = "https://www.runkeeper.com/opps/authorize" +urllib.urlencode(params) + + return HttpResponseRedirect(url) + +# Get list of workouts available on Runkeeper +def get_runkeeper_workout_list(user): + r = Rower.objects.get(user=user) + if (r.runkeepertoken == '') or (r.runkeepertoken is None): + s = "Token doesn't exist. Need to authorize" + return custom_exception_handler(401,s) + else: + # ready to fetch. Hurray + authorizationstring = str('Bearer ' + r.runkeepertoken) + headers = {'Authorization': authorizationstring, + 'user-agent': 'sanderroosendaal', + 'Content-Type': 'application/json'} + url = "https://api.runkeeper.com/fitnessActivities" + s = requests.get(url,headers=headers) + + return s + +# Get workout summary data by Runkeeper ID +def get_runkeeper_workout(user,runkeeperid): + r = Rower.objects.get(user=user) + if (r.runkeepertoken == '') or (r.runkeepertoken is None): + return custom_exception_handler(401,s) + s = "Token doesn't exist. Need to authorize" + else: + # ready to fetch. Hurray + authorizationstring = str('Bearer ' + r.runkeepertoken) + headers = {'Authorization': authorizationstring, + 'user-agent': 'sanderroosendaal', + 'Content-Type': 'application/json'} + url = "https://api.runkeeper.com/fitnessActivities/"+str(runkeeperid) + s = requests.get(url,headers=headers) + + return s + +# Generate Workout data for Runkeeper (a TCX file) +def createrunkeeperworkoutdata(w): + filename = w.csvfilename + try: + row = rowingdata(filename) + tcxfilename = filename[:-4]+'.tcx' + row.exporttotcx(tcxfilename,notes=w.notes) + except: + tcxfilename = 0 + + return tcxfilename + +# Upload the TCX file to Runkeeper and set the workout activity type +# to rowing on Runkeeper +def handle_runkeeperexport(f2,workoutname,runkeepertoken,description=''): + # w = Workout.objects.get(id=workoutid) + client = runkeeperlib.Client(access_token=runkeepertoken) + + act = client.upload_activity(f2,'tcx',name=workoutname) + try: + res = act.wait(poll_interval=5.0,timeout=30) + message = 'Workout successfully synchronized to Runkeeper' + except: + res = 0 + + + + # description doesn't work yet. Have to wait for runkeeperlib to update + if res: + act = client.update_activity(res.id,activity_type='Rowing',description=description) + else: + message = 'Runkeeper upload timed out.' + return (0,message) + + return (res.id,message) + + diff --git a/rowers/templates/runkeeper_list_import.html b/rowers/templates/runkeeper_list_import.html new file mode 100644 index 00000000..5cda8c0d --- /dev/null +++ b/rowers/templates/runkeeper_list_import.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% load staticfiles %} +{% load rowerfilters %} + +{% block title %}Workouts{% endblock %} + +{% block content %} +

Available on Runkeeper

+ {% if workouts %} + + + + + + + + + + + + {% for workout in workouts %} + + + + + + + + + {% endfor %} + +
Import Date/Time Duration Total Distance Type
+Import{{ workout|lookup:'starttime' }}{{ workout|lookup:'duration' }} {{ workout|lookup:'distance' }} m{{ workout|lookup:'type' }}
+ {% else %} +

No workouts found

+ {% endif %} +{% endblock %} diff --git a/rowers/urls.py b/rowers/urls.py index 6a524940..512e1356 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -212,6 +212,8 @@ urlpatterns = [ url(r'^workout/stravaimport/(\d+)/$',views.workout_getstravaworkout_view), url(r'^workout/sporttracksimport/$',views.workout_sporttracksimport_view), url(r'^workout/sporttracksimport/(\d+)/$',views.workout_getsporttracksworkout_view), + url(r'^workout/runkeeperimport/$',views.workout_runkeeperimport_view), + url(r'^workout/runkeeperimport/(\d+)/$',views.workout_getrunkeeperworkout_view), url(r'^workout/(\d+)/deleteconfirm$',views.workout_delete_confirm_view), url(r'^workout/(\d+)/c2uploadw/$',views.workout_c2_upload_view), url(r'^workout/(\d+)/stravauploadw/$',views.workout_strava_upload_view), @@ -251,6 +253,7 @@ urlpatterns = [ url(r'^me/revokeapp/(\d+)$',views.rower_revokeapp_view), url(r'^me/stravaauthorize/$',views.rower_strava_authorize), url(r'^me/sporttracksauthorize/$',views.rower_sporttracks_authorize), + url(r'^me/runkeeperauthorize/$',views.rower_runkeeper_authorize), url(r'^me/sporttracksrefresh/$',views.rower_sporttracks_token_refresh), url(r'^me/c2refresh/$',views.rower_c2_token_refresh), url(r'^me/favoritecharts/$',views.rower_favoritecharts_view), diff --git a/rowers/views.py b/rowers/views.py index d1aca901..603a4e44 100644 --- a/rowers/views.py +++ b/rowers/views.py @@ -49,10 +49,16 @@ from c2stuff import C2NoTokenError from iso8601 import ParseError import stravastuff import sporttracksstuff +import runkeeperstuff import ownapistuff from ownapistuff import TEST_CLIENT_ID, TEST_CLIENT_SECRET, TEST_REDIRECT_URI -from rowsandall_app.settings import C2_CLIENT_ID, C2_REDIRECT_URI, C2_CLIENT_SECRET, STRAVA_CLIENT_ID, STRAVA_REDIRECT_URI, STRAVA_CLIENT_SECRET -from rowsandall_app.settings import SPORTTRACKS_CLIENT_ID, SPORTTRACKS_REDIRECT_URI, SPORTTRACKS_CLIENT_SECRET +from rowsandall_app.settings import ( + C2_CLIENT_ID, C2_REDIRECT_URI, C2_CLIENT_SECRET, + STRAVA_CLIENT_ID, STRAVA_REDIRECT_URI, STRAVA_CLIENT_SECRET, + SPORTTRACKS_CLIENT_ID, SPORTTRACKS_REDIRECT_URI, + SPORTTRACKS_CLIENT_SECRET, + RUNKEEPER_CLIENT_ID,RUNKEEPER_REDIRECT_URI,RUNKEEPER_CLIENT_SECRET, + ) import requests import json @@ -1053,6 +1059,25 @@ def rower_strava_authorize(request): import urllib url = "https://www.strava.com/oauth/authorize?"+ urllib.urlencode(params) + return HttpResponseRedirect(url) + +# Runkeeper authorization +@login_required() +def rower_runkeeper_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": RUNKEEPER_CLIENT_ID, + "response_type": "code", + "state": state, + "redirect_uri": RUNKEEPER_REDIRECT_URI} + + import urllib + url = "https://runkeeper.com/apps/authorize?"+ urllib.urlencode(params) + + return HttpResponseRedirect(url) # SportTracks Authorization @@ -1199,6 +1224,19 @@ def rower_process_stravacallback(request): message = "Something went wrong with the Strava authorization" return imports_view(request,message=message) +# Process Runkeeper callback +@login_required() +def rower_process_runkeepercallback(request): + code = request.GET['code'] + access_token = runkeeperstuff.get_token(code) + + r = Rower.objects.get(user=request.user) + r.runkeepertoken = access_token + + r.save() + + successmessage = "Tokens stored. Good to go" + return imports_view(request,successmessage=successmessage) # Process SportTracks callback @login_required() @@ -4425,8 +4463,46 @@ def workout_stravaimport_view(request,message=""): }) return HttpResponse(res) - -# The page where you select which SportTracks page to import + +# The page where you select which RunKeeper workout to import +@login_required() +def workout_runkeeperimport_view(request,message=""): + res = runkeeperstuff.get_runkeeper_workout_list(request.user) + if (res.status_code != 200): + if (res.status_code == 401): + r = Rower.objects.get(user=request.user) + if (r.runkeepertoken == '') or (r.runkeepertoken is None): + s = "Token doesn't exist. Need to authorize" + return HttpResponseRedirect("/rowers/me/runkeeperauthorize/") + message = "Something went wrong in workout_runkeeperimport_view" + if settings.DEBUG: + return HttpResponse(res) + else: + url = reverse(workouts_view, + kwargs = { + 'message': str(message) + }) + return HttpResponseRedirect(url) + else: + workouts = [] + for item in res.json()['items']: + d = int(float(item['total_distance'])) + i = getidfromsturi(item['uri']) + ttot = str(datetime.timedelta(seconds=int(float(item['duration'])))) + s = item['start_time'] + r = item['type'] + keys = ['id','distance','duration','starttime','type'] + values = [i,d,ttot,s,r] + res = dict(zip(keys,values)) + workouts.append(res) + return render(request,'runkeeper_list_import.html', + {'workouts':workouts, + 'message':message, + }) + + return HttpResponse(res) + +# The page where you select which SportTracks workout to import @login_required() def workout_sporttracksimport_view(request,message=""): res = sporttracksstuff.get_sporttracks_workout_list(request.user) @@ -4582,6 +4658,34 @@ def workout_getstravaworkout_view(request,stravaid): return HttpResponseRedirect(url) +# Imports a workout from Runkeeper +@login_required() +def workout_getrunkeeperworkout_view(request,runkeeperid): + res = runkeeperstuff.get_runkeeper_workout(request.user,runkeeperid) + print res.json() + data = res.json() + + return HttpResponse(data) + + id,message = add_workout_from_stdata(request.user,runkeeperid,data) + w = Workout.objects.get(id=id) + w.uploadedtorunkeeper=runkeeperid + w.save() + if message: + url = reverse(workout_edit_view, + kwargs = { + 'id':id, + 'message':message, + }) + else: + url = reverse(workout_edit_view, + kwargs = { + 'id':id, + }) + return HttpResponseRedirect(url) + + + # Imports a workout from SportTracks @login_required() def workout_getsporttracksworkout_view(request,sporttracksid): diff --git a/rowsandall_app/settings.py b/rowsandall_app/settings.py index 81a166df..0cd505dd 100644 --- a/rowsandall_app/settings.py +++ b/rowsandall_app/settings.py @@ -226,6 +226,12 @@ SPORTTRACKS_CLIENT_ID = CFG['sporttracks_client_id'] SPORTTRACKS_CLIENT_SECRET = CFG['sporttracks_client_secret'] SPORTTRACKS_REDIRECT_URI = "http://rowsandall.com/sporttracks_callback" +# Runkeeper + +RUNKEEPER_CLIENT_ID = CFG['runkeeper_client_id'] +RUNKEEPER_CLIENT_SECRET = CFG['runkeeper_client_secret'] +RUNKEEPER_REDIRECT_URI = "http://rowsandall.com/runkeeper_callback" + # RQ stuff RQ_QUEUES = { diff --git a/rowsandall_app/urls.py b/rowsandall_app/urls.py index 0f1d9fbf..08834cb7 100644 --- a/rowsandall_app/urls.py +++ b/rowsandall_app/urls.py @@ -55,6 +55,7 @@ urlpatterns += [ url(r'^call\_back',rowersviews.rower_process_callback), url(r'^stravacall\_back',rowersviews.rower_process_stravacallback), url(r'^sporttracks\_callback',rowersviews.rower_process_sporttrackscallback), + url(r'^runkeeper\_callback',rowersviews.rower_process_runkeepercallback), url(r'^twitter\_callback',rowersviews.rower_process_twittercallback), url(r'^i18n/', include('django.conf.urls.i18n')), ]