From d70df62b63f739fc38935529837e18ba994a9096 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Sun, 16 Apr 2017 14:14:20 +0200 Subject: [PATCH] import working --- rowers/templates/imports.html | 19 +- rowers/templates/underarmour_list_import.html | 37 +++ rowers/templatetags/rowerfilters.py | 28 ++ rowers/underarmourstuff.py | 41 ++- rowers/urls.py | 2 + rowers/views.py | 245 +++++++++++++++++- static/img/UAbtn.png | Bin 0 -> 9013 bytes 7 files changed, 359 insertions(+), 13 deletions(-) create mode 100644 rowers/templates/underarmour_list_import.html create mode 100644 static/img/UAbtn.png diff --git a/rowers/templates/imports.html b/rowers/templates/imports.html index 6bfda942..9177b93f 100644 --- a/rowers/templates/imports.html +++ b/rowers/templates/imports.html @@ -43,12 +43,22 @@

Import workouts from RunKeeper

+
+
+

+ Under Armour logo +

+
+
+

Import workouts from MapMyFitness/UnderArmour

+
+

Connect

-
+

Click one of the below logos to connect to the service of your choice. You only need to do this once. After that, the site will have access until you revoke the authorization for the "rowingdata" app.

@@ -67,10 +77,13 @@
-
-
+
+

connect with RunKeeper

+
+

connect with Under Armour

+
diff --git a/rowers/templates/underarmour_list_import.html b/rowers/templates/underarmour_list_import.html new file mode 100644 index 00000000..34fb26a3 --- /dev/null +++ b/rowers/templates/underarmour_list_import.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% load staticfiles %} +{% load rowerfilters %} + +{% block title %}Workouts{% endblock %} + +{% block content %} +

Available on MapMyFitness (UnderArmour)

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

No workouts found. We only list workouts with time data series.

+ {% endif %} +{% endblock %} diff --git a/rowers/templatetags/rowerfilters.py b/rowers/templatetags/rowerfilters.py index d3a610fd..31dc8a04 100644 --- a/rowers/templatetags/rowerfilters.py +++ b/rowers/templatetags/rowerfilters.py @@ -1,5 +1,6 @@ from django import template from time import strftime +import dateutil.parser register = template.Library() @@ -27,6 +28,17 @@ def strfdeltah(tdelta): return res +def secondstotimestring(tdelta): + hours, rest = divmod(tdelta,3600) + minutes,seconds = divmod(rest,60) + res = "{hours:0>2}:{minutes:0>2}:{seconds:0>2}".format( + hours=hours, + minutes=minutes, + seconds=seconds, + ) + + return res + @register.filter def durationprint(d,dstring): if (d == None): @@ -57,6 +69,22 @@ def lookup(dict, key): s = s[:22] return s +@register.filter +def ualookup(dict, key): + s = dict.get(key) + + if key=='distance': + s = int(float(s)) + + if key=='duration': + s = secondstotimestring(int(s)) + + + if key=='starttime': + s = dateutil.parser.parse(s) + + return s + @register.filter(name='times') def times(number): return range(number) diff --git a/rowers/underarmourstuff.py b/rowers/underarmourstuff.py index 7d9b2df9..0d11281f 100644 --- a/rowers/underarmourstuff.py +++ b/rowers/underarmourstuff.py @@ -171,9 +171,9 @@ def get_underarmour_workout_list(user): headers = {'Authorization': authorizationstring, 'Api-Key': UNDERARMOUR_CLIENT_KEY, 'user-agent': 'sanderroosendaal', - 'user':'v7.1/user/'+str(get_userid(r.underarmourtoken))+'/', 'Content-Type': 'application/json'} - url = "https://api.ua.com/v7.1/workout/" + url = "https://api.ua.com/v7.1/workout/?user="+str(get_userid(r.underarmourtoken)) + s = requests.get(url,headers=headers) return s @@ -191,7 +191,7 @@ def get_underarmour_workout(user,underarmourid): 'Api-Key': UNDERARMOUR_CLIENT_KEY, 'user-agent': 'sanderroosendaal', 'Content-Type': 'application/json'} - url = "https://api.ua.com/v7.1/workout/"+str(underarmourid)+"/" + url = "https://api.ua.com/v7.1/workout/"+str(underarmourid)+"/?field_set=time_series" s = requests.get(url,headers=headers) return s @@ -292,13 +292,36 @@ def createunderarmourworkoutdata(w): return data -# Obtain Underarmour Workout ID from the response returned on successful -# upload -def getidfromresponse(response): - uri = response.headers["Location"] - id = uri[len(uri)-9:] +# Obtain Underarmour Workout ID and activity type +def get_idfromuri(user,links): + id = links['self'][0]['id'] + typeid = links['activity_type'][0]['id'] + + + typename = get_typefromid(typeid,user) + + return id,typename + +def get_typefromid(typeid,user): + r = Rower.objects.get(user=user) + authorizationstring = str('Bearer ' + r.underarmourtoken) + headers = {'Authorization': authorizationstring, + 'Api-Key': UNDERARMOUR_CLIENT_KEY, + 'user-agent': 'sanderroosendaal', + 'Content-Type': 'application/json'} + import urllib + url = "https://api.ua.com/v7.1/activity_type/"+str(typeid) + response = requests.get(url,headers=headers) + + me_json = response.json() + + try: + res = me_json['name'] + except KeyError: + res = 0 + + return res - return int(id) # Get user id, having access token diff --git a/rowers/urls.py b/rowers/urls.py index 5a72d091..3f409e35 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -229,6 +229,8 @@ urlpatterns = [ 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/underarmourimport/$',views.workout_underarmourimport_view), + url(r'^workout/underarmourimport/(\d+)/$',views.workout_getunderarmourworkout_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), diff --git a/rowers/views.py b/rowers/views.py index bdf96a25..54975258 100644 --- a/rowers/views.py +++ b/rowers/views.py @@ -214,6 +214,15 @@ def get_time(second): def getidfromsturi(uri,length=8): return uri[len(uri)-length:] +def splituadata(lijst): + t = [] + y = [] + for d in lijst: + t.append(d[0]) + y.append(d[1]) + + return np.array(t),np.array(y) + def splitrunkeeperlatlongdata(lijst,tname,latname,lonname): t = [] lat = [] @@ -816,6 +825,173 @@ def add_workout_from_stdata(user,importid,data): + unixtime = cum_time+starttimeunix + unixtime[0] = starttimeunix + + df['TimeStamp (sec)'] = unixtime + + + dt = np.diff(cum_time).mean() + wsize = round(5./dt) + + velo2 = stravastuff.ewmovingaverage(velo,wsize) + + df[' Stroke500mPace (sec/500m)'] = 500./velo2 + + + df = df.fillna(0) + + df.sort_values(by='TimeStamp (sec)',ascending=True) + + timestr = strftime("%Y%m%d-%H%M%S") + + csvfilename ='media/Import_'+str(importid)+'.csv' + + res = df.to_csv(csvfilename+'.gz',index_label='index', + compression='gzip') + + id,message = dataprep.save_workout_database(csvfilename,r, + workouttype=workouttype, + title=title, + notes=comments) + + return (id,message) + +# Create workout from SportTracks Data, which are slightly different +# than Strava or Concept2 data +def add_workout_from_underarmourdata(user,importid,data): + workouttype = 'water' + + try: + comments = data['notes'] + except: + comments = '' + + try: + thetimezone = tz(data['start_locale_timezone']) + except: + thetimezone = 'UTC' + + r = Rower.objects.get(user=user) + try: + rowdatetime = iso8601.parse_date(data['start_datetime']) + except iso8601.ParseError: + try: + rowdatetime = datetime.datetime.strptime(data['start_datetime'],"%Y-%m-%d %H:%M:%S") + rowdatetime = thetimezone.localize(rowdatetime).astimezone(utc) + except: + try: + rowdatetime = dateutil.parser.parse(data['start_datetime']) + rowdatetime = thetimezone.localize(rowdatetime).astimezone(utc) + except: + rowdatetime = datetime.datetime.strptime(data['date'],"%Y-%m-%d %H:%M:%S") + rowdatetime = thetimezone.localize(rowdatetime).astimezone(utc) + starttimeunix = mktime(rowdatetime.utctimetuple()) + + + try: + title = data['name'] + except: + title = "Imported data" + + timeseries = data['time_series'] + + # position, distance, speed, cadence, power, + + res = splituadata(timeseries['distance']) + + distance = res[1] + + times_distance = res[0] + + print distance[0:5] + print times_distance[0:5] + + try: + l = timeseries['position'] + + res = splituadata(l) + times_location = res[0] + latlong = res[1] + latcoord = [] + loncoord = [] + + for coord in latlong: + lat = coord['lat'] + lon = coord['lng'] + latcoord.append(lat) + loncoord.append(lon) + except: + times_location = times_distance + latcoord = np.zeros(len(times_distance)) + loncoord = np.zeros(len(times_distance)) + if workouttype == 'water': + workouttype = 'rower' + + try: + res = splituadata(timeseries['cadence']) + times_spm = res[0] + spm = res[1] + except KeyError: + times_spm = times_distance + spm = 0*times_distance + + try: + res = splituadata(timeseries['heartrate']) + hr = res[1] + times_hr = res[0] + except KeyError: + times_hr = times_distance + hr = 0*times_distance + + + # create data series and remove duplicates + distseries = pd.Series(distance,index=times_distance) + distseries = distseries.groupby(distseries.index).first() + latseries = pd.Series(latcoord,index=times_location) + latseries = latseries.groupby(latseries.index).first() + lonseries = pd.Series(loncoord,index=times_location) + lonseries = lonseries.groupby(lonseries.index).first() + spmseries = pd.Series(spm,index=times_spm) + spmseries = spmseries.groupby(spmseries.index).first() + hrseries = pd.Series(hr,index=times_hr) + hrseries = hrseries.groupby(hrseries.index).first() + + + # Create dicts and big dataframe + d = { + ' Horizontal (meters)': distseries, + ' latitude': latseries, + ' longitude': lonseries, + ' Cadence (stokes/min)': spmseries, + ' HRCur (bpm)' : hrseries, + } + + + + df = pd.DataFrame(d) + + df = df.groupby(level=0).last() + + cum_time = df.index.values + df[' ElapsedTime (sec)'] = cum_time + + velo = df[' Horizontal (meters)'].diff()/df[' ElapsedTime (sec)'].diff() + + df[' Power (watts)'] = 0.0*velo + + nr_rows = len(velo.values) + + df[' DriveLength (meters)'] = np.zeros(nr_rows) + df[' StrokeDistance (meters)'] = np.zeros(nr_rows) + df[' DriveTime (ms)'] = np.zeros(nr_rows) + df[' StrokeRecoveryTime (ms)'] = np.zeros(nr_rows) + df[' AverageDriveForce (lbs)'] = np.zeros(nr_rows) + df[' PeakDriveForce (lbs)'] = np.zeros(nr_rows) + df[' lapIdx'] = np.zeros(nr_rows) + + + unixtime = cum_time+starttimeunix unixtime[0] = starttimeunix @@ -1401,7 +1577,7 @@ def rower_underarmour_authorize(request): redirect_uri = UNDERARMOUR_REDIRECT_URI redirect_uri = 'http://localhost:8000/underarmour_callback' - url = 'https://api.mapmyfitness.com/v7.1/oauth2/authorize/?' \ + url = 'https://www.mapmyfitness.com/v7.1/oauth2/authorize/?' \ 'client_id={0}&response_type=code&redirect_uri={1}'.format( UNDERARMOUR_CLIENT_KEY, redirect_uri ) @@ -4896,6 +5072,50 @@ def workout_runkeeperimport_view(request,message=""): return HttpResponse(res) +# The page where you select which RunKeeper workout to import +@login_required() +def workout_underarmourimport_view(request,message=""): + res = underarmourstuff.get_underarmour_workout_list(request.user) + if (res.status_code != 200): + if (res.status_code == 401): + r = Rower.objects.get(user=request.user) + if (r.underarmourtoken == '') or (r.underarmourtoken is None): + s = "Token doesn't exist. Need to authorize" + return HttpResponseRedirect("/rowers/me/underarmourauthorize/") + message = "Something went wrong in workout_underarmourimport_view" + if settings.DEBUG: + return HttpResponse(res) + else: + url = reverse(workouts_view, + kwargs = { + 'message': str(message) + }) + return HttpResponseRedirect(url) + else: + workouts = [] + items = res.json()['_embedded']['workouts'] + for item in items: + if 'has_time_series' in item: + if item['has_time_series']: + s = item['start_datetime'] + i,r = underarmourstuff.get_idfromuri(request.user,item['_links']) + n = item['name'] + d = item['aggregates']['distance_total'] + ttot = item['aggregates']['active_time_total'] + keys = ['id','distance','duration','starttime','type'] + values = [i,d,ttot,s,r] + thedict = dict(zip(keys,values)) + + workouts.append(thedict) + + return render(request,'underarmour_list_import.html', + {'workouts':workouts, + 'teams':get_my_teams(request.user), + 'message':message, + }) + + return HttpResponse(res) + # The page where you select which SportTracks workout to import @login_required() def workout_sporttracksimport_view(request,message=""): @@ -5078,6 +5298,29 @@ def workout_getrunkeeperworkout_view(request,runkeeperid): }) return HttpResponseRedirect(url) +# Imports a workout from Underarmour +@login_required() +def workout_getunderarmourworkout_view(request,underarmourid): + res = underarmourstuff.get_underarmour_workout(request.user,underarmourid) + data = res.json() + + id,message = add_workout_from_underarmourdata(request.user,underarmourid,data) + w = Workout.objects.get(id=id) + w.uploadedtounderarmour=underarmourid + 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 diff --git a/static/img/UAbtn.png b/static/img/UAbtn.png new file mode 100644 index 0000000000000000000000000000000000000000..7cfdf18d7aa7ce45c682d0b45f564cc512242de7 GIT binary patch literal 9013 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 LS+YXh`0M`z;EiX@ literal 0 HcmV?d00001