From 30506818a5750c9bbfeffa838fabc4251e3836c9 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Mon, 4 Jun 2018 16:11:03 +0200 Subject: [PATCH 1/8] auth working, getting user data a problem --- rowers/models.py | 6 + rowers/polarstuff.py | 168 +++++++++++++++++++++ rowers/templates/email.html | 2 +- rowers/templates/imports.html | 12 +- rowers/urls.py | 1 + rowers/views.py | 68 ++++++++- rowsandall_app/settings.py | 5 + rowsandall_app/urls.py | 1 + static/img/Polar_API_logo_3-horizontal.png | Bin 0 -> 18963 bytes static/img/Polar_connectwith_btn_white.png | Bin 0 -> 18773 bytes 10 files changed, 252 insertions(+), 11 deletions(-) create mode 100644 rowers/polarstuff.py create mode 100644 static/img/Polar_API_logo_3-horizontal.png create mode 100644 static/img/Polar_connectwith_btn_white.png diff --git a/rowers/models.py b/rowers/models.py index 945bbd39..28abaf6f 100644 --- a/rowers/models.py +++ b/rowers/models.py @@ -655,6 +655,12 @@ class Rower(models.Model): tprefreshtoken = models.CharField(default='',max_length=1000, blank=True,null=True) + polartoken = models.CharField(default='',max_length=1000,blank=True,null=True) + polartokenexpirydate = models.DateTimeField(blank=True,null=True) + polarrefreshtoken = models.CharField(default='',max_length=1000, + blank=True,null=True) + polaruserid = models.IntegerField(default=0) + stravatoken = models.CharField(default='',max_length=200,blank=True,null=True) stravaexportas = models.CharField(default="Rowing", max_length=30, diff --git a/rowers/polarstuff.py b/rowers/polarstuff.py new file mode 100644 index 00000000..65ac068d --- /dev/null +++ b/rowers/polarstuff.py @@ -0,0 +1,168 @@ +# All the functionality needed to connect to Strava + +# 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 +import gzip +import base64 + +# 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 rowers.models import checkworkoutuser +import dataprep +from dataprep import columndict + +import stravalib +from stravalib.exc import ActivityUploadFailed,TimeoutExceeded + +from rowsandall_app.settings import ( + POLAR_CLIENT_ID, POLAR_REDIRECT_URI, POLAR_CLIENT_SECRET, + ) + +# 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 + +# Custom error class - to raise a NoTokenError +class PolarNoTokenError(Exception): + def __init__(self,value): + self.value=value + + def __str__(self): + return repr(self.value) + + +# Exchange access code for long-lived access token +def get_token(code): + + post_data = {"grant_type": "authorization_code", + "code": code, + "redirect_uri": POLAR_REDIRECT_URI, + } + + auth_string = '{id}:{secret}'.format( + id= POLAR_CLIENT_ID, + secret=POLAR_CLIENT_SECRET + ) + + headers = { 'Authorization': 'Basic %s' % base64.b64encode(auth_string) } + + response = requests.post("https://polarremote.com/v2/oauth2/token", + data=post_data, + headers=headers) + + + try: + token_json = response.json() + thetoken = token_json['access_token'] + expires_in = token_json['expires_in'] + user_id = token_json['x_user_id'] + except KeyError: + thetoken = 0 + expires_in = 0 + + return [thetoken,expires_in,user_id] + +# 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": POLAR_CLIENT_ID, + "response_type": "code", + "redirect_uri": POLAR_REDIRECT_URI, + "scope":"write"} + import urllib + url = "https://flow.polar.com/oauth2/authorization" +urllib.urlencode(params) + + return HttpResponseRedirect(url) + +def get_polar_workout_list(user): + r = Rower.objects.get(user=user) + if (r.polartoken == '') or (r.polartoken is None): + s = "Token doesn't exist. Need to authorize" + return custom_exception_handler(401,s) + elif (timezone.now()>r.polartokenexpirydate): + s = "Token expired. Needs to refresh" + return custom_exception_handler(401,s) + else: + authorizationstring = str('Bearer ' + r.polartoken) + headers = {'Authorization':authorizationstring, + 'Accept': 'application/json'} + + url = 'https://polaraccesslink.com/v3/users/{userid}/exercise-transactions'.format( + userid = r.polaruserid + ) + + response = requests.post(url, headers=headers) + + return response + +def get_polar_user_info(user): + r = Rower.objects.get(user=user) + if (r.polartoken == '') or (r.polartoken is None): + s = "Token doesn't exist. Need to authorize" + return custom_exception_handler(401,s) + elif (timezone.now()>r.polartokenexpirydate): + s = "Token expired. Needs to refresh" + return custom_exception_handler(401,s) + else: + authorizationstring = str('Bearer ' + r.polartoken) + headers = { + 'Authorization':authorizationstring, + 'Accept': 'application/json' + } + + params = { + 'user-id': r.polaruserid + } + + url = 'https://polaraccesslink.com/v3/users/{userid}'.format( + userid = r.polaruserid + ) + + + print url + response = requests.get(url, headers=headers) + + return response + diff --git a/rowers/templates/email.html b/rowers/templates/email.html index 02f23929..562f8615 100644 --- a/rowers/templates/email.html +++ b/rowers/templates/email.html @@ -92,7 +92,7 @@ When the site is down, this is the appropriate channel to look for apologies, up 602 00 Brno
Czech Republic
IČ: 070 48 572
- DIČ: CZ 070 48 572
+ DIČ: CZ 070 48 572 (Nejsme plátce DPH)
Datová schránka: 7897syr
Email: info@rowsandall.com
The company is registered in the business register at the diff --git a/rowers/templates/imports.html b/rowers/templates/imports.html index d6e5a6af..17f27503 100644 --- a/rowers/templates/imports.html +++ b/rowers/templates/imports.html @@ -1,9 +1,9 @@ {% extends "base.html" %} - {% block title %}Contact Us{% endblock title %} + {% block title %}Import Workouts{% endblock title %} {% block content %}
-

Import Workouts

+

Import Workouts

@@ -56,7 +56,7 @@
-

Connect

+

Connect

Click one of the below logos to connect to the service of your choice. @@ -81,9 +81,13 @@

connect with RunKeeper

-
+

connect with Under Armour

+
+

connect with Polar

+
diff --git a/rowers/urls.py b/rowers/urls.py index de5d498d..5e29e8b5 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -379,6 +379,7 @@ urlpatterns = [ url(r'^rower/edit/(?P\d+)$',views.rower_edit_view), url(r'^me/edit/(.+.*)/$',views.rower_edit_view), url(r'^me/c2authorize/$',views.rower_c2_authorize), + url(r'^me/polarauthorize/$',views.rower_polar_authorize), url(r'^me/revokeapp/(?P\d+)$',views.rower_revokeapp_view), url(r'^me/stravaauthorize/$',views.rower_strava_authorize), url(r'^me/sporttracksauthorize/$',views.rower_sporttracks_authorize), diff --git a/rowers/views.py b/rowers/views.py index 78f6c367..af6e4264 100644 --- a/rowers/views.py +++ b/rowers/views.py @@ -21,6 +21,7 @@ from django.views.decorators.csrf import csrf_exempt from matplotlib.backends.backend_agg import FigureCanvas import gc from pyparsing import ParseException +from uuid import uuid4 from django.shortcuts import render from django.http import ( @@ -93,6 +94,8 @@ from sporttracksstuff import SportTracksNoTokenError,sporttracks_open from tpstuff import TPNoTokenError,tp_open from iso8601 import ParseError import stravastuff +import polarstuff +from polarstuff import PolarNoTokenError from stravastuff import StravaNoTokenError import sporttracksstuff import underarmourstuff @@ -104,6 +107,7 @@ 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, + POLAR_CLIENT_ID, POLAR_REDIRECT_URI, POLAR_CLIENT_SECRET, SPORTTRACKS_CLIENT_ID, SPORTTRACKS_REDIRECT_URI, SPORTTRACKS_CLIENT_SECRET, UNDERARMOUR_CLIENT_ID, UNDERARMOUR_REDIRECT_URI, @@ -2358,7 +2362,7 @@ def workout_sporttracks_upload_view(request,id=0): def rower_c2_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()) scope = "user:read,results:write" params = {"client_id": C2_CLIENT_ID, @@ -2373,7 +2377,7 @@ def rower_c2_authorize(request): def rower_strava_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": STRAVA_CLIENT_ID, @@ -2385,12 +2389,30 @@ def rower_strava_authorize(request): return HttpResponseRedirect(url) +# Polar Authorization +@login_required() +def rower_polar_authorize(request): + + state = str(uuid4()) + + params = {"client_id": POLAR_CLIENT_ID, + "response_type": "code", + "redirect_uri": POLAR_REDIRECT_URI, + "state": state, +# "scope":"accesslink.read_all" + } + url = "https://flow.polar.com/oauth2/authorization?" +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, @@ -2408,7 +2430,7 @@ def rower_runkeeper_authorize(request): def rower_sporttracks_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": SPORTTRACKS_CLIENT_ID, @@ -2426,7 +2448,7 @@ def rower_sporttracks_authorize(request): def rower_underarmour_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()) redirect_uri = UNDERARMOUR_REDIRECT_URI @@ -2443,7 +2465,7 @@ def rower_underarmour_authorize(request): 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", @@ -2608,6 +2630,40 @@ def test_reverse_view(request): def rower_process_twittercallback(request): return "dummy" +# Process Polar Callback +@login_required() +def rower_process_polarcallback(request): + try: + code = request.GET['code'] + except MultiValueDictKeyError: + try: + message = request.GET['error'] + except MultiValueDictKeyError: + message = "access error" + + messages.error(request,message) + return imports_view(request) + + res = polarstuff.get_token(code) + + access_token = res[0] + expires_in = res[1] + user_id = res[2] + + expirydatetime = timezone.now()+datetime.timedelta(seconds=expires_in) + + r = getrower(request.user) + r.polartoken = access_token + r.polartokenexpirydate = expirydatetime + r.polaruserid = user_id + + r.save() + + successmessage = "Tokens stored. Good to go" + messages.info(request,successmessage) + return imports_view(request) + + # Process Strava Callback @login_required() def rower_process_stravacallback(request): diff --git a/rowsandall_app/settings.py b/rowsandall_app/settings.py index 9bfc9d1e..661ae2ad 100644 --- a/rowsandall_app/settings.py +++ b/rowsandall_app/settings.py @@ -254,6 +254,11 @@ RUNKEEPER_CLIENT_ID = CFG['runkeeper_client_id'] RUNKEEPER_CLIENT_SECRET = CFG['runkeeper_client_secret'] RUNKEEPER_REDIRECT_URI = CFG['runkeeper_callback'] +# Polar Flow + +POLAR_CLIENT_ID = CFG['polarflow_client_id'] +POLAR_CLIENT_SECRET = CFG['polarflow_client_secret'] +POLAR_REDIRECT_URI = CFG['polarflow_callback'] # Under Armour diff --git a/rowsandall_app/urls.py b/rowsandall_app/urls.py index 64e2a3dc..5fdc8511 100644 --- a/rowsandall_app/urls.py +++ b/rowsandall_app/urls.py @@ -71,6 +71,7 @@ urlpatterns += [ url(r'^stravacall\_back',rowersviews.rower_process_stravacallback), url(r'^sporttracks\_callback',rowersviews.rower_process_sporttrackscallback), url(r'^underarmour\_callback',rowersviews.rower_process_underarmourcallback), + url(r'^polarflowcallback',rowersviews.rower_process_polarcallback), url(r'^runkeeper\_callback',rowersviews.rower_process_runkeepercallback), url(r'^tp\_callback',rowersviews.rower_process_tpcallback), url(r'^twitter\_callback',rowersviews.rower_process_twittercallback), diff --git a/static/img/Polar_API_logo_3-horizontal.png b/static/img/Polar_API_logo_3-horizontal.png new file mode 100644 index 0000000000000000000000000000000000000000..7f6dc054a09823bce4e9da2ec41158d1bb6d80b3 GIT binary patch literal 18963 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 Date: Mon, 4 Jun 2018 17:41:04 +0200 Subject: [PATCH 2/8] some experimenting, still 204 responses --- rowers/polarstuff.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/rowers/polarstuff.py b/rowers/polarstuff.py index 65ac068d..27deba7b 100644 --- a/rowers/polarstuff.py +++ b/rowers/polarstuff.py @@ -137,7 +137,7 @@ def get_polar_workout_list(user): return response -def get_polar_user_info(user): +def get_polar_user_info(user,physical=False): r = Rower.objects.get(user=user) if (r.polartoken == '') or (r.polartoken is None): s = "Token doesn't exist. Need to authorize" @@ -156,13 +156,22 @@ def get_polar_user_info(user): 'user-id': r.polaruserid } - url = 'https://polaraccesslink.com/v3/users/{userid}'.format( - userid = r.polaruserid + if not physical: + url = 'https://polaraccesslink.com/v3/users/{userid}'.format( + userid = r.polaruserid ) + else: + url = 'https://www.polaraccesslink.com/v3/users/{userid}/physical-information-transactions/'.format( + userid = r.polaruserid + ) print url - response = requests.get(url, headers=headers) + + if physical: + response = requests.post(url, headers=headers) + else: + response = requests.get(url, headers=headers) return response From 3dd8921f544f33db195b84eedff89b47e8403896 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Tue, 5 Jun 2018 08:22:59 +0200 Subject: [PATCH 3/8] now getting user info --- rowers/polarstuff.py | 10 +++++++++- rowers/views.py | 11 ++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/rowers/polarstuff.py b/rowers/polarstuff.py index 27deba7b..a45842bf 100644 --- a/rowers/polarstuff.py +++ b/rowers/polarstuff.py @@ -101,7 +101,7 @@ def get_token(code): return [thetoken,expires_in,user_id] # Make authorization URL including random string -def make_authorization_url(request): +def make_authorization_url(): # Generate a random string for the state parameter # Save it for use later to prevent xsrf attacks from uuid import uuid4 @@ -114,6 +114,8 @@ def make_authorization_url(request): import urllib url = "https://flow.polar.com/oauth2/authorization" +urllib.urlencode(params) + print url + return HttpResponseRedirect(url) def get_polar_workout_list(user): @@ -133,6 +135,12 @@ def get_polar_workout_list(user): userid = r.polaruserid ) +# url = 'https://polaraccesslink.com/v3/users/{userid}/activity-transactions'.format( +# userid = r.polaruserid +# ) + + print url + response = requests.post(url, headers=headers) return response diff --git a/rowers/views.py b/rowers/views.py index af6e4264..e36866b7 100644 --- a/rowers/views.py +++ b/rowers/views.py @@ -2399,10 +2399,11 @@ def rower_polar_authorize(request): "response_type": "code", "redirect_uri": POLAR_REDIRECT_URI, "state": state, -# "scope":"accesslink.read_all" + "scope":"accesslink.read_all" } url = "https://flow.polar.com/oauth2/authorization?" +urllib.urlencode(params) - + print url + return HttpResponseRedirect(url) @@ -2644,11 +2645,7 @@ def rower_process_polarcallback(request): messages.error(request,message) return imports_view(request) - res = polarstuff.get_token(code) - - access_token = res[0] - expires_in = res[1] - user_id = res[2] + access_token, expires_in, user_id = polarstuff.get_token(code) expirydatetime = timezone.now()+datetime.timedelta(seconds=expires_in) From aa0431740b39e773835f8ba0b3d889b419498760 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Tue, 5 Jun 2018 16:16:16 +0200 Subject: [PATCH 4/8] gets a tcx for new workouts --- rowers/polarstuff.py | 121 ++++++++++++++++++++++-- rowers/templates/polar_list_import.html | 42 ++++++++ rowers/urls.py | 2 + rowers/views.py | 43 ++++++++- 4 files changed, 197 insertions(+), 11 deletions(-) create mode 100644 rowers/templates/polar_list_import.html diff --git a/rowers/polarstuff.py b/rowers/polarstuff.py index a45842bf..3efd6d3d 100644 --- a/rowers/polarstuff.py +++ b/rowers/polarstuff.py @@ -118,8 +118,11 @@ def make_authorization_url(): return HttpResponseRedirect(url) -def get_polar_workout_list(user): +def get_polar_workouts(user): r = Rower.objects.get(user=user) + + exercise_list = [] + if (r.polartoken == '') or (r.polartoken is None): s = "Token doesn't exist. Need to authorize" return custom_exception_handler(401,s) @@ -131,19 +134,52 @@ def get_polar_workout_list(user): headers = {'Authorization':authorizationstring, 'Accept': 'application/json'} + headers2 = { + 'Authorization':authorizationstring, + } + url = 'https://polaraccesslink.com/v3/users/{userid}/exercise-transactions'.format( userid = r.polaruserid ) -# url = 'https://polaraccesslink.com/v3/users/{userid}/activity-transactions'.format( -# userid = r.polaruserid -# ) - - print url response = requests.post(url, headers=headers) + print url + print r.polartoken + print response.status_code - return response + if response.status_code == 201: + transactionid = response.json()['transaction-id'] + url = 'https://polaraccesslink.com/v3/users/{userid}/exercise-transactions/{transactionid}'.format( + transactionid = transactionid, + userid = r.polaruserid + ) + + response = requests.get(url, headers=headers) + if response.status_code == 200: + exerciseurls = response.json()['exercises'] + for exerciseurl in exerciseurls: + response = requests.get(exerciseurl,headers=headers) + if response.status_code == 200: + exercise_dict = response.json() + tcxuri = exerciseurl+'/tcx' + response = requests.get(tcxuri,headers=headers2) + if response.status_code == 200: + filename = 'media/polarimport{id}.tcx'.format( + id = exercise_dict['id'] + ) + with open(filename,'w') as fop: + fop.write(response.text) + exercise_dict['filename'] = filename + else: + exercise_dict['filename'] = None + + exercise_list.append(exercise_dict) + + # commit transaction + requests.put(url, headers=headers) + + return exercise_list def get_polar_user_info(user,physical=False): r = Rower.objects.get(user=user) @@ -160,6 +196,7 @@ def get_polar_user_info(user,physical=False): 'Accept': 'application/json' } + params = { 'user-id': r.polaruserid } @@ -174,12 +211,78 @@ def get_polar_user_info(user,physical=False): ) - print url - if physical: response = requests.post(url, headers=headers) else: response = requests.get(url, headers=headers) return response + + +def get_polar_workout(user,id,transactionid): + r = Rower.objects.get(user=user) + if (r.polartoken == '') or (r.polartoken is None): + s = "Token doesn't exist. Need to authorize" + return custom_exception_handler(401,s) + elif (timezone.now()>r.polartokenexpirydate): + s = "Token expired. Needs to refresh" + return custom_exception_handler(401,s) + else: + authorizationstring = str('Bearer ' + r.polartoken) + headers = { + 'Authorization':authorizationstring, + 'Accept': 'application/json' + } + + + url = 'https://polaraccesslink.com/v3/users/{userid}/exercise-transactions'.format( + userid = r.polaruserid + ) + + + response = requests.post(url, headers=headers) + + if response.status_code == 201: + transactionid = response.json()['transaction-id'] + url = 'https://polaraccesslink.com/v3/users/{userid}/exercise-transactions/{transactionid}'.format( + transactionid = transactionid, + userid = r.polaruserid + ) + + response = requests.get(url, headers=headers) + if response.status_code == 200: + exerciseurls = response.json()['exercises'] + for exerciseurl in exerciseurls: + response = requests.gedt(exerciseurl,headers=headers) + if response.status_code == 200: + exercise_dict = response.json() + thisid = exercise_dict['id'] + if thisid == id: + url = 'https://polaraccesslink.com/v3/users/{userid}/exercise-transactions/{transactionid}/exercises/{exerciseid}/tcx'.format( + userid = r.polaruserid, + transactionid = transactionid, + exerciseid = id + ) + + print "Get TCX" + print url + response = requests.get(url,headers = headers2) + print response.status_code + + if response.status_code == 200: + print response.text + + result = response.text + # commit transaction + response = requests.put(url,headers=headers) + else: + result = None + + return result + + return None + + + + diff --git a/rowers/templates/polar_list_import.html b/rowers/templates/polar_list_import.html new file mode 100644 index 00000000..0792fd58 --- /dev/null +++ b/rowers/templates/polar_list_import.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% load staticfiles %} +{% load rowerfilters %} + +{% block title %}Workouts{% endblock %} + +{% block content %} +

New Workouts Available on Polar Flow

+ +

Due to a limitation in Polar Flow's API, we can only access new workouts.

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

No new workouts found

+{% endif %} +{% endblock %} diff --git a/rowers/urls.py b/rowers/urls.py index 5e29e8b5..2550da97 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -328,6 +328,8 @@ urlpatterns = [ url(r'^workout/sporttracksimport/$',views.workout_sporttracksimport_view), url(r'^workout/sporttracksimport/(?P\d+)/$',views.workout_getsporttracksworkout_view), url(r'^workout/sporttracksimport/all/$',views.workout_getsporttracksworkout_all), + url(r'^workout/polarimport/$',views.workout_polarimport_view), + url(r'^workout/polarimport/(?P\d+)/(?P\d+)$',views.workout_getpolarworkout_view), url(r'^workout/runkeeperimport/$',views.workout_runkeeperimport_view), url(r'^workout/runkeeperimport/(?P\d+)/$',views.workout_getrunkeeperworkout_view), url(r'^workout/underarmourimport/$',views.workout_underarmourimport_view), diff --git a/rowers/views.py b/rowers/views.py index e36866b7..32e25a3a 100644 --- a/rowers/views.py +++ b/rowers/views.py @@ -23,6 +23,8 @@ import gc from pyparsing import ParseException from uuid import uuid4 +import isodate + from django.shortcuts import render from django.http import ( HttpResponse, HttpResponseRedirect, @@ -2399,10 +2401,9 @@ def rower_polar_authorize(request): "response_type": "code", "redirect_uri": POLAR_REDIRECT_URI, "state": state, - "scope":"accesslink.read_all" +# "scope":"accesslink.read_all" } url = "https://flow.polar.com/oauth2/authorization?" +urllib.urlencode(params) - print url return HttpResponseRedirect(url) @@ -9644,6 +9645,37 @@ def workout_underarmourimport_view(request,message=""): return HttpResponse(res) +# the page where you select which Polar workout to Import +@login_required() +def workout_polarimport_view(request): + exercises = polarstuff.get_polar_workouts(request.user) + workouts = [] + for exercise in exercises: + try: + d = exercise['distance'] + except KeyError: + d = 0 + + i = exercise['id'] + transactionid = exercise['transaction-id'] + starttime = exercise['start-time'] + rowtype = exercise['sport'] + durationstring = exercise['duration'] + duration = isodate.parse_duration(durationstring) + keys = ['id','distance','duration','starttime','type','transactionid'] + values = [i,d,duration,starttime,rowtype,transactionid] + res = dict(zip(keys,values)) + workouts.append(res) + + return render(request, 'polar_list_import.html', + { + 'workouts':workouts, + 'teams':get_my_teams(request.user), + }) + + + + # The page where you select which SportTracks workout to import @login_required() def workout_sporttracksimport_view(request,message=""): @@ -9820,6 +9852,13 @@ def workout_c2import_view(request,page=1,message=""): 'page':page, }) +# Import a workout from Polar +@login_required() +def workout_getpolarworkout_view(request,polarid,transactionid): + result = polarstuff.get_polar_workout(request.user,polarid,transactionid) + + return HttpResponse(result) + # Import a workout from Strava @login_required() def workout_getstravaworkout_view(request,stravaid): From 6b03bc26c6be1d556232d7036c90cce04a5adaeb Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Tue, 5 Jun 2018 16:43:58 +0200 Subject: [PATCH 5/8] changed to v3-example for debugging --- rowers/polarstuff.py | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/rowers/polarstuff.py b/rowers/polarstuff.py index 3efd6d3d..4c718145 100644 --- a/rowers/polarstuff.py +++ b/rowers/polarstuff.py @@ -41,6 +41,8 @@ from rowsandall_app.settings import ( POLAR_CLIENT_ID, POLAR_REDIRECT_URI, POLAR_CLIENT_SECRET, ) +baseurl = 'https://polaraccesslink.com/v3-example' + # Custom exception handler, returns a 401 HTTP message # with exception details in the json data def custom_exception_handler(exc,message): @@ -114,7 +116,6 @@ def make_authorization_url(): import urllib url = "https://flow.polar.com/oauth2/authorization" +urllib.urlencode(params) - print url return HttpResponseRedirect(url) @@ -138,19 +139,17 @@ def get_polar_workouts(user): 'Authorization':authorizationstring, } - url = 'https://polaraccesslink.com/v3/users/{userid}/exercise-transactions'.format( + url = baseurl+'/users/{userid}/exercise-transactions'.format( userid = r.polaruserid ) response = requests.post(url, headers=headers) - print url - print r.polartoken - print response.status_code + if response.status_code == 201: transactionid = response.json()['transaction-id'] - url = 'https://polaraccesslink.com/v3/users/{userid}/exercise-transactions/{transactionid}'.format( + url = baseurl+'/users/{userid}/exercise-transactions/{transactionid}'.format( transactionid = transactionid, userid = r.polaruserid ) @@ -158,21 +157,29 @@ def get_polar_workouts(user): response = requests.get(url, headers=headers) if response.status_code == 200: exerciseurls = response.json()['exercises'] + print 'Found {n} workouts'.format( + n=len(exerciseurls) + ) for exerciseurl in exerciseurls: response = requests.get(exerciseurl,headers=headers) if response.status_code == 200: exercise_dict = response.json() + print response.json() tcxuri = exerciseurl+'/tcx' response = requests.get(tcxuri,headers=headers2) if response.status_code == 200: filename = 'media/polarimport{id}.tcx'.format( id = exercise_dict['id'] ) - with open(filename,'w') as fop: + print 'downloading workout {id} to {filename}'.format( + filename=filename, + id = exercise_dict['id'] + ) + with open(filename,'wb') as fop: fop.write(response.text) exercise_dict['filename'] = filename else: - exercise_dict['filename'] = None + exercise_dict['filename'] = '' exercise_list.append(exercise_dict) @@ -202,7 +209,7 @@ def get_polar_user_info(user,physical=False): } if not physical: - url = 'https://polaraccesslink.com/v3/users/{userid}'.format( + url = baseurl+'/users/{userid}'.format( userid = r.polaruserid ) else: @@ -236,7 +243,7 @@ def get_polar_workout(user,id,transactionid): } - url = 'https://polaraccesslink.com/v3/users/{userid}/exercise-transactions'.format( + url = baseurl+'/users/{userid}/exercise-transactions'.format( userid = r.polaruserid ) @@ -245,7 +252,7 @@ def get_polar_workout(user,id,transactionid): if response.status_code == 201: transactionid = response.json()['transaction-id'] - url = 'https://polaraccesslink.com/v3/users/{userid}/exercise-transactions/{transactionid}'.format( + url = baseurl+'/users/{userid}/exercise-transactions/{transactionid}'.format( transactionid = transactionid, userid = r.polaruserid ) @@ -259,16 +266,13 @@ def get_polar_workout(user,id,transactionid): exercise_dict = response.json() thisid = exercise_dict['id'] if thisid == id: - url = 'https://polaraccesslink.com/v3/users/{userid}/exercise-transactions/{transactionid}/exercises/{exerciseid}/tcx'.format( + url = baseurl+'/users/{userid}/exercise-transactions/{transactionid}/exercises/{exerciseid}/tcx'.format( userid = r.polaruserid, transactionid = transactionid, exerciseid = id ) - print "Get TCX" - print url response = requests.get(url,headers = headers2) - print response.status_code if response.status_code == 200: print response.text From 278e99a38c4f722f6382df9929115f95b64badae Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Tue, 5 Jun 2018 21:54:47 +0200 Subject: [PATCH 6/8] working, downloading tcx --- rowers/polarstuff.py | 30 ++++++++++++++++--------- rowers/templates/polar_list_import.html | 15 +++++++++---- rowers/urls.py | 1 - rowers/views.py | 6 ----- 4 files changed, 31 insertions(+), 21 deletions(-) diff --git a/rowers/polarstuff.py b/rowers/polarstuff.py index 4c718145..6d5d804a 100644 --- a/rowers/polarstuff.py +++ b/rowers/polarstuff.py @@ -34,9 +34,13 @@ from rowers.models import checkworkoutuser import dataprep from dataprep import columndict +from io import StringIO + import stravalib from stravalib.exc import ActivityUploadFailed,TimeoutExceeded +from django_mailbox.models import Message,Mailbox,MessageAttachment + from rowsandall_app.settings import ( POLAR_CLIENT_ID, POLAR_REDIRECT_URI, POLAR_CLIENT_SECRET, ) @@ -148,6 +152,9 @@ def get_polar_workouts(user): if response.status_code == 201: + + workoutsbox = Mailbox.objects.filter(name='workouts')[0] + transactionid = response.json()['transaction-id'] url = baseurl+'/users/{userid}/exercise-transactions/{transactionid}'.format( transactionid = transactionid, @@ -157,26 +164,29 @@ def get_polar_workouts(user): response = requests.get(url, headers=headers) if response.status_code == 200: exerciseurls = response.json()['exercises'] - print 'Found {n} workouts'.format( - n=len(exerciseurls) - ) for exerciseurl in exerciseurls: response = requests.get(exerciseurl,headers=headers) if response.status_code == 200: exercise_dict = response.json() - print response.json() tcxuri = exerciseurl+'/tcx' response = requests.get(tcxuri,headers=headers2) if response.status_code == 200: - filename = 'media/polarimport{id}.tcx'.format( - id = exercise_dict['id'] - ) - print 'downloading workout {id} to {filename}'.format( - filename=filename, + filename = 'media/mailbox_attachments/polarimport{id}.tcx'.format( id = exercise_dict['id'] ) + with open(filename,'wb') as fop: - fop.write(response.text) + fop.write(response.content) + + msg = Message(mailbox=workoutsbox, + from_header=user.email, + subject = 'Import from Polar Flow') + + msg.save() + + a = MessageAttachment(message=msg,document=filename) + a.save() + exercise_dict['filename'] = filename else: exercise_dict['filename'] = '' diff --git a/rowers/templates/polar_list_import.html b/rowers/templates/polar_list_import.html index 0792fd58..3e61acf2 100644 --- a/rowers/templates/polar_list_import.html +++ b/rowers/templates/polar_list_import.html @@ -5,16 +5,18 @@ {% block title %}Workouts{% endblock %} {% block content %} -

New Workouts Available on Polar Flow

+

New Workouts Imported From Polar Flow

-

Due to a limitation in Polar Flow's API, we can only access new workouts.

+

Due to a limitation in Polar Flow's API, we can only access new workouts. We + have imported the following workouts and are now processing them. You will + receive email when the workouts are ready.

{% if workouts %}
- + @@ -25,7 +27,12 @@ {% for workout in workouts %} + {% if workout|lookup:'filename' == '' %} + NO + {% else %} + YES + {% endif %} + diff --git a/rowers/urls.py b/rowers/urls.py index 2550da97..be8f6ea8 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -329,7 +329,6 @@ urlpatterns = [ url(r'^workout/sporttracksimport/(?P\d+)/$',views.workout_getsporttracksworkout_view), url(r'^workout/sporttracksimport/all/$',views.workout_getsporttracksworkout_all), url(r'^workout/polarimport/$',views.workout_polarimport_view), - url(r'^workout/polarimport/(?P\d+)/(?P\d+)$',views.workout_getpolarworkout_view), url(r'^workout/runkeeperimport/$',views.workout_runkeeperimport_view), url(r'^workout/runkeeperimport/(?P\d+)/$',views.workout_getrunkeeperworkout_view), url(r'^workout/underarmourimport/$',views.workout_underarmourimport_view), diff --git a/rowers/views.py b/rowers/views.py index 32e25a3a..266bd7b3 100644 --- a/rowers/views.py +++ b/rowers/views.py @@ -9852,12 +9852,6 @@ def workout_c2import_view(request,page=1,message=""): 'page':page, }) -# Import a workout from Polar -@login_required() -def workout_getpolarworkout_view(request,polarid,transactionid): - result = polarstuff.get_polar_workout(request.user,polarid,transactionid) - - return HttpResponse(result) # Import a workout from Strava @login_required() From a277f71af3f3d6511ff4053a2a8f7ae6afb86472 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Tue, 5 Jun 2018 22:18:58 +0200 Subject: [PATCH 7/8] works now - send workout to email message --- rowers/management/commands/processemail.py | 13 +++++++++++++ rowers/polarstuff.py | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/rowers/management/commands/processemail.py b/rowers/management/commands/processemail.py index bcb27787..f2a1c65e 100644 --- a/rowers/management/commands/processemail.py +++ b/rowers/management/commands/processemail.py @@ -47,6 +47,8 @@ def processattachment(rower, fileobj, title, uploadoptions,testing=False): filename = fileobj.name except AttributeError: filename = fileobj[6:] + if testing: + print 'Attribute Error', filename # test if file exists and is not empty @@ -54,12 +56,20 @@ def processattachment(rower, fileobj, title, uploadoptions,testing=False): with open('media/'+filename,'r') as fop: line = fop.readline() except IOError: + if testing: + print 'IOError',filename,'media/'+filename return 0 + if testing: + print 'Creating workout from email' + workoutid = [ make_new_workout_from_email(rower, filename, title,testing=testing) ] + if testing: + print 'Workout id = {workoutid}'.format(workoutid=workoutid) + if workoutid[0]: link = settings.SITE_URL+reverse( rower.defaultlandingpage, @@ -185,6 +195,9 @@ class Command(BaseCommand): ) else: # move attachment and make workout + if testing: + print name + print attachment.document workoutid = processattachment( rower, attachment.document, name, uploadoptions, testing=testing diff --git a/rowers/polarstuff.py b/rowers/polarstuff.py index 6d5d804a..3bad578a 100644 --- a/rowers/polarstuff.py +++ b/rowers/polarstuff.py @@ -16,6 +16,7 @@ from math import sin,cos,atan2,sqrt import os,sys import gzip import base64 +import yaml # Django from django.shortcuts import render_to_response @@ -154,6 +155,14 @@ def get_polar_workouts(user): if response.status_code == 201: workoutsbox = Mailbox.objects.filter(name='workouts')[0] + uploadoptions = { + 'makeprivate':False, + } + + bodyyaml = yaml.safe_dump( + uploadoptions, + default_flow_style=False + ) transactionid = response.json()['transaction-id'] url = baseurl+'/users/{userid}/exercise-transactions/{transactionid}'.format( @@ -180,11 +189,12 @@ def get_polar_workouts(user): msg = Message(mailbox=workoutsbox, from_header=user.email, - subject = 'Import from Polar Flow') + subject = 'Import from Polar Flow', + body=bodyyaml) msg.save() - a = MessageAttachment(message=msg,document=filename) + a = MessageAttachment(message=msg,document=filename[6:]) a.save() exercise_dict['filename'] = filename From 63d52eb42a992a288b48115c539d2b8e5baacd9d Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Wed, 6 Jun 2018 10:36:30 +0200 Subject: [PATCH 8/8] added auto export for all platforms --- rowers/mailprocessing.py | 3 + rowers/management/commands/processemail.py | 4 ++ rowers/models.py | 23 +++++++ rowers/polarstuff.py | 47 ++++++++++++-- rowers/templates/imports.html | 38 ++++++++++- rowers/uploads.py | 14 ++-- rowers/urls.py | 3 +- rowers/views.py | 75 +++++++++++++++++----- 8 files changed, 179 insertions(+), 28 deletions(-) diff --git a/rowers/mailprocessing.py b/rowers/mailprocessing.py index f27ca372..ffedcf99 100644 --- a/rowers/mailprocessing.py +++ b/rowers/mailprocessing.py @@ -91,6 +91,9 @@ def make_new_workout_from_email(rower, datafile, name, cntr=0,testing=False): path='media/')[6:] fileformat = fileformat[2] + if testing: + print 'Fileformat = ',fileformat + if fileformat == 'unknown': # extension = datafilename[-4:].lower() # fcopy = "media/"+datafilename[:-4]+"_copy"+extension diff --git a/rowers/management/commands/processemail.py b/rowers/management/commands/processemail.py index f2a1c65e..c8c3f2f9 100644 --- a/rowers/management/commands/processemail.py +++ b/rowers/management/commands/processemail.py @@ -20,6 +20,7 @@ from rowingdata import rowingdata as rrdata import rowers.uploads as uploads from rowers.mailprocessing import make_new_workout_from_email, send_confirm +import rowers.polarstuff as polarstuff workoutmailbox = Mailbox.objects.get(name='workouts') failedmailbox = Mailbox.objects.get(name='Failed') @@ -147,6 +148,9 @@ class Command(BaseCommand): """Run the Email processing command """ def handle(self, *args, **options): + polar_available = polarstuff.get_polar_notifications() + res = polarstuff.get_all_new_workouts(polar_available) + messages = Message.objects.filter(mailbox_id = workoutmailbox.id) message_ids = [m.id for m in messages] attachments = MessageAttachment.objects.filter( diff --git a/rowers/models.py b/rowers/models.py index 28abaf6f..5649595a 100644 --- a/rowers/models.py +++ b/rowers/models.py @@ -642,24 +642,29 @@ class Rower(models.Model): c2token = models.CharField(default='',max_length=200,blank=True,null=True) tokenexpirydate = models.DateTimeField(blank=True,null=True) c2refreshtoken = models.CharField(default='',max_length=200,blank=True,null=True) + c2_auto_export = models.BooleanField(default=False) sporttrackstoken = models.CharField(default='',max_length=200,blank=True,null=True) sporttrackstokenexpirydate = models.DateTimeField(blank=True,null=True) sporttracksrefreshtoken = models.CharField(default='',max_length=200, blank=True,null=True) + sporttracks_auto_export = models.BooleanField(default=False) underarmourtoken = models.CharField(default='',max_length=200,blank=True,null=True) underarmourtokenexpirydate = models.DateTimeField(blank=True,null=True) underarmourrefreshtoken = models.CharField(default='',max_length=200, blank=True,null=True) + mapmyfitness_auto_export = models.BooleanField(default=False) tptoken = models.CharField(default='',max_length=1000,blank=True,null=True) tptokenexpirydate = models.DateTimeField(blank=True,null=True) tprefreshtoken = models.CharField(default='',max_length=1000, blank=True,null=True) + trainingpeaks_auto_export = models.BooleanField(default=False) polartoken = models.CharField(default='',max_length=1000,blank=True,null=True) polartokenexpirydate = models.DateTimeField(blank=True,null=True) polarrefreshtoken = models.CharField(default='',max_length=1000, blank=True,null=True) polaruserid = models.IntegerField(default=0) + polar_auto_import = models.BooleanField(default=False) stravatoken = models.CharField(default='',max_length=200,blank=True,null=True) stravaexportas = models.CharField(default="Rowing", @@ -667,8 +672,10 @@ class Rower(models.Model): choices=stravatypes, verbose_name="Export Workouts to Strava as") + strava_auto_export = models.BooleanField(default=False) runkeepertoken = models.CharField(default='',max_length=200, blank=True,null=True) + runkeeper_auto_export = models.BooleanField(default=False) # Plan plans = ( @@ -1996,6 +2003,22 @@ class RowerPowerZonesForm(ModelForm): return cleaned_data +# Form to set rower's Auto Import and Export settings +class RowerImportExportForm(ModelForm): + class Meta: + model = Rower + fields = [ + 'polar_auto_import', + 'c2_auto_export', + 'mapmyfitness_auto_export', + 'runkeeper_auto_export', + 'sporttracks_auto_export', + 'strava_auto_export', + 'trainingpeaks_auto_export', + ] + + + # Form to set rower's Email and Weight category class AccountRowerForm(ModelForm): class Meta: diff --git a/rowers/polarstuff.py b/rowers/polarstuff.py index 3bad578a..5bf1a535 100644 --- a/rowers/polarstuff.py +++ b/rowers/polarstuff.py @@ -17,6 +17,7 @@ import os,sys import gzip import base64 import yaml +from uuid import uuid4 # Django from django.shortcuts import render_to_response @@ -46,7 +47,8 @@ from rowsandall_app.settings import ( POLAR_CLIENT_ID, POLAR_REDIRECT_URI, POLAR_CLIENT_SECRET, ) -baseurl = 'https://polaraccesslink.com/v3-example' +#baseurl = 'https://polaraccesslink.com/v3-example' +baseurl = 'https://polaraccesslink.com/v3' # Custom exception handler, returns a 401 HTTP message # with exception details in the json data @@ -111,7 +113,6 @@ def get_token(code): def make_authorization_url(): # 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": POLAR_CLIENT_ID, @@ -124,6 +125,43 @@ def make_authorization_url(): return HttpResponseRedirect(url) +def get_polar_notifications(): + url = baseurl+'/notifications' + state = str(uuid4()) + auth_string = '{id}:{secret}'.format( + id= POLAR_CLIENT_ID, + secret=POLAR_CLIENT_SECRET + ) + + headers = { 'Authorization': 'Basic %s' % base64.b64encode(auth_string) } + + response = requests.get(url, headers=headers) + + available_data = [] + + if response.status_code == 200: + available_data = response.json()['available-user-data'] + + return available_data + +def get_all_new_workouts(available_data,testing=False): + for record in available_data: + if testing: + print record + if record['data-type'] == 'EXERCISE': + try: + r = Rower.objects.get(polaruserid=record['user-id']) + u = r.user + if r.polar_auto_import: + exercise_list = get_polar_workouts(u) + if testing: + print exercise_list + except Rower.DoesNotExist: + pass + + return 1 + + def get_polar_workouts(user): r = Rower.objects.get(user=user) @@ -180,8 +218,9 @@ def get_polar_workouts(user): tcxuri = exerciseurl+'/tcx' response = requests.get(tcxuri,headers=headers2) if response.status_code == 200: - filename = 'media/mailbox_attachments/polarimport{id}.tcx'.format( - id = exercise_dict['id'] + filename = 'media/mailbox_attachments/{code}_{id}.tcx'.format( + id = exercise_dict['id'], + code = uuid4().hex[:16] ) with open(filename,'wb') as fop: diff --git a/rowers/templates/imports.html b/rowers/templates/imports.html index 17f27503..857802a2 100644 --- a/rowers/templates/imports.html +++ b/rowers/templates/imports.html @@ -53,6 +53,16 @@

Import workouts from MapMyFitness/UnderArmour

+
+
+

+ Polar logo +

+
+
+

Import workouts from Polar Flow.

Note: No workout selection possible. Automatically imports all new workouts

+
+
@@ -60,7 +70,8 @@

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 + You only need to do this once. After that, the site will have + access until you revoke the authorization for the "rowingdata" app.

@@ -89,6 +100,31 @@ alt="connect with Polar" width="130">

+ +

Auto Import/Export Settings

+ +

Use the form below to set your auto import/export settings. + As we implement auto import/export for various partner sites, this form will be expanded.

+ +

Auto Import = rowsandall.com will regularly poll the partner site for new + workouts and automatically import new workouts +

+ +

Auto Export = New workouts uploaded to rowsandall.com will be automatically synchronized + to the partner site

+ +
+
+
Import Imported Date Duration Distance
- Import {{ workout|lookup:'starttime' }} {{ workout|lookup:'duration' }} {{ workout|lookup:'distance' }} m
+ {{ form.as_table }} +
+ {% csrf_token %} +
+ +
+ +
+
diff --git a/rowers/uploads.py b/rowers/uploads.py index a5a4fc61..a667e200 100644 --- a/rowers/uploads.py +++ b/rowers/uploads.py @@ -374,14 +374,14 @@ def make_private(w,options): return 1 def do_sync(w,options): - if 'upload_to_C2' in options and options['upload_to_C2']: + if ('upload_to_C2' in options and options['upload_to_C2']) or w.user.c2_auto_export: try: message,id = c2stuff.workout_c2_upload(w.user.user,w) except c2stuff.C2NoTokenError: id = 0 message = "Something went wrong with the Concept2 sync" - if 'upload_to_Strava' in options and options['upload_to_Strava']: + if ('upload_to_Strava' in options and options['upload_to_Strava']) or w.user.strava_auto_export: try: message,id = stravastuff.workout_strava_upload( w.user.user,w @@ -390,8 +390,8 @@ def do_sync(w,options): id = 0 message = "Please connect to Strava first" - - if 'upload_to_SportTracks' in options and options['upload_to_SportTracks']: + + if ('upload_to_SportTracks' in options and options['upload_to_SportTracks']) or w.user.sporttracks_auto_export: try: message,id = sporttracksstuff.workout_sporttracks_upload( w.user.user,w @@ -401,7 +401,7 @@ def do_sync(w,options): id = 0 - if 'upload_to_RunKeeper' in options and options['upload_to_RunKeeper']: + if ('upload_to_RunKeeper' in options and options['upload_to_RunKeeper']) or w.user.runkeeper_auto_export: try: message,id = runkeeperstuff.workout_runkeeper_upload( w.user.user,w @@ -410,7 +410,7 @@ def do_sync(w,options): message = "Please connect to Runkeeper first" id = 0 - if 'upload_to_MapMyFitness' in options and options['upload_to_MapMyFitness']: + if ('upload_to_MapMyFitness' in options and options['upload_to_MapMyFitness']) or w.user.mapmyfitness_auto_export: try: message,id = underarmourstuff.workout_ua_upload( w.user.user,w @@ -420,7 +420,7 @@ def do_sync(w,options): id = 0 - if 'upload_to_TrainingPeaks' in options and options['upload_to_TrainingPeaks']: + if ('upload_to_TrainingPeaks' in options and options['upload_to_TrainingPeaks']) or w.user.trainingpeaks_auto_export: try: message,id = tpstuff.workout_tp_upload( w.user.user,w diff --git a/rowers/urls.py b/rowers/urls.py index be8f6ea8..b8d22ef1 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -120,7 +120,8 @@ urlpatterns = [ url(r'^404/$', TemplateView.as_view(template_name='404.html'),name='404'), url(r'^400/$', TemplateView.as_view(template_name='400.html'),name='400'), url(r'^403/$', TemplateView.as_view(template_name='403.html'),name='403'), - url(r'^imports/$', TemplateView.as_view(template_name='imports.html'), name='imports'), +# url(r'^imports/$', TemplateView.as_view(template_name='imports.html'), name='imports'), + url(r'^imports/$', views.imports_view), url(r'^exportallworkouts/?$',views.workouts_summaries_email_view), url(r'^update_empower$',views.rower_update_empower_view), url(r'^agegroupcp/(?P\d+)$',views.agegroupcpview), diff --git a/rowers/views.py b/rowers/views.py index 266bd7b3..1c41a8ba 100644 --- a/rowers/views.py +++ b/rowers/views.py @@ -72,7 +72,7 @@ from rowers.models import ( WorkoutComment,WorkoutCommentForm,RowerExportForm, CalcAgePerformance,PowerTimeFitnessMetric,PlannedSessionForm, PlannedSessionFormSmall,GeoCourseEditForm,VirtualRace, - VirtualRaceForm,VirtualRaceResultForm, + VirtualRaceForm,VirtualRaceResultForm,RowerImportExportForm ) from rowers.models import ( FavoriteForm,BaseFavoriteFormSet,SiteAnnouncement,BasePlannedSessionFormSet @@ -1234,7 +1234,10 @@ def add_workout_from_strokedata(user,importid,data,strokedata, # Create CSV file name and save data to CSV file - csvfilename ='media/Import_'+str(importid)+'.csv' + csvfilename ='media/{code}_{importid}.csv'.format( + importid=importid, + code = uuid4().hex[:16] + ) res = df.to_csv(csvfilename+'.gz',index_label='index', compression='gzip') @@ -1424,7 +1427,11 @@ def add_workout_from_runkeeperdata(user,importid,data): timestr = strftime("%Y%m%d-%H%M%S") - csvfilename ='media/Import_'+str(importid)+'.csv' +# csvfilename ='media/Import_'+str(importid)+'.csv' + csvfilename ='media/{code}_{importid}.csv'.format( + importid=importid, + code = uuid4().hex[:16] + ) res = df.to_csv(csvfilename+'.gz',index_label='index', compression='gzip') @@ -1590,7 +1597,11 @@ def add_workout_from_stdata(user,importid,data): timestr = strftime("%Y%m%d-%H%M%S") - csvfilename ='media/Import_'+str(importid)+'.csv' +# csvfilename ='media/Import_'+str(importid)+'.csv' + csvfilename ='media/{code}_{importid}.csv'.format( + importid=importid, + code = uuid4().hex[:16] + ) res = df.to_csv(csvfilename+'.gz',index_label='index', compression='gzip') @@ -1757,7 +1768,11 @@ def add_workout_from_underarmourdata(user,importid,data): timestr = strftime("%Y%m%d-%H%M%S") - csvfilename ='media/Import_'+str(importid)+'.csv' +# csvfilename ='media/Import_'+str(importid)+'.csv' + csvfilename ='media/{code}_{importid}.csv'.format( + importid=importid, + code = uuid4().hex[:16] + ) res = df.to_csv(csvfilename+'.gz',index_label='index', compression='gzip') @@ -2612,12 +2627,31 @@ def rower_process_callback(request): # The imports page @login_required() -def imports_view(request,successmessage="",message=""): - messages.info(request,successmessage) - messages.error(request,message) +def imports_view(request): + + r = getrower(request.user) + + if request.method == 'POST': + form = RowerImportExportForm(request.POST) + if form.is_valid(): + cd = form.cleaned_data + + for attr, value in cd.items(): + setattr(r,attr,value) + + r.save() + + # polar_auto_import = cd['polar_auto_import'] + # r.polar_auto_import = polar_auto_import + # r.save() + else: + form = RowerImportExportForm(instance=r) + + return render(request,"imports.html", { 'teams':get_my_teams(request.user), + 'form':form, }) # Just for testing purposes @@ -9650,12 +9684,22 @@ def workout_underarmourimport_view(request,message=""): def workout_polarimport_view(request): exercises = polarstuff.get_polar_workouts(request.user) workouts = [] + + try: + a = exercises.status_code + if a == 401: + messages.error(request,'Not authorized. You need to connect to Polar first') + url = reverse(imports_view) + return HttpResponseRedirect(url) + except: + pass + for exercise in exercises: try: d = exercise['distance'] except KeyError: d = 0 - + i = exercise['id'] transactionid = exercise['transaction-id'] starttime = exercise['start-time'] @@ -9667,6 +9711,7 @@ def workout_polarimport_view(request): res = dict(zip(keys,values)) workouts.append(res) + return render(request, 'polar_list_import.html', { 'workouts':workouts, @@ -10428,7 +10473,7 @@ def workout_upload_view(request, request.session['async_tasks'] = [(jobid,'make_plot')] # upload to C2 - if (upload_to_c2): + if (upload_to_c2) or (w.user.c2_auto_export): try: message,id = c2stuff.workout_c2_upload(request.user,w) except C2NoTokenError: @@ -10439,7 +10484,7 @@ def workout_upload_view(request, else: messages.error(request,message) - if (upload_to_strava): + if (upload_to_strava) or (w.user.strava_auto_export): try: message,id = stravastuff.workout_strava_upload( request.user,w @@ -10452,7 +10497,7 @@ def workout_upload_view(request, else: messages.error(request,message) - if (upload_to_st): + if (upload_to_st) or (w.user.sporttracks_auto_export): try: message,id = sporttracksstuff.workout_sporttracks_upload( request.user,w @@ -10465,7 +10510,7 @@ def workout_upload_view(request, else: messages.error(request,message) - if (upload_to_rk): + if (upload_to_rk) or (w.user.runkeeper_auto_export): try: message,id = runkeeperstuff.workout_runkeeper_upload( request.user,w @@ -10480,7 +10525,7 @@ def workout_upload_view(request, messages.error(request,message) - if (upload_to_ua): + if (upload_to_ua) or (w.user.mapmyfitness_auto_export): try: message,id = underarmourstuff.workout_ua_upload( request.user,w @@ -10495,7 +10540,7 @@ def workout_upload_view(request, messages.error(request,message) - if (upload_to_tp): + if (upload_to_tp) or (w.user.trainingpeaks_auto_export): try: message,id = tpstuff.workout_tp_upload( request.user,w