From 63b5147d8da5a9901a55875507ce4a6194590d4f Mon Sep 17 00:00:00 2001 From: Antoine Gagne Date: Fri, 4 Mar 2016 09:39:41 -0500 Subject: [PATCH 1/2] Initial commit for Hexoskin --- tapiriik/services/Hexoskin/__init__.py | 1 + tapiriik/services/Hexoskin/hexoskin.py | 256 ++++++++++++++++++ tapiriik/services/__init__.py | 2 + tapiriik/services/service.py | 5 +- tapiriik/web/static/img/services/hexoskin.png | Bin 0 -> 4947 bytes .../web/static/img/services/hexoskin_l.png | Bin 0 -> 6542 bytes tapiriik/web/views/privacy.py | 1 + 7 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 tapiriik/services/Hexoskin/__init__.py create mode 100644 tapiriik/services/Hexoskin/hexoskin.py create mode 100644 tapiriik/web/static/img/services/hexoskin.png create mode 100644 tapiriik/web/static/img/services/hexoskin_l.png diff --git a/tapiriik/services/Hexoskin/__init__.py b/tapiriik/services/Hexoskin/__init__.py new file mode 100644 index 000000000..ea32d8973 --- /dev/null +++ b/tapiriik/services/Hexoskin/__init__.py @@ -0,0 +1 @@ +from .hexoskin import * diff --git a/tapiriik/services/Hexoskin/hexoskin.py b/tapiriik/services/Hexoskin/hexoskin.py new file mode 100644 index 000000000..d9469665b --- /dev/null +++ b/tapiriik/services/Hexoskin/hexoskin.py @@ -0,0 +1,256 @@ +from tapiriik.settings import WEB_ROOT, HEXOSKIN_CLIENT_SECRET, HEXOSKIN_CLIENT_ID +from tapiriik.services.service_base import ServiceAuthenticationType, ServiceBase +from tapiriik.services.service_record import ServiceRecord +from tapiriik.services.interchange import UploadedActivity, ActivityType, ActivityStatistic, ActivityStatisticUnit +from tapiriik.services.api import APIException, UserException, UserExceptionType, APIExcludeActivity +from tapiriik.services.tcx import TCXIO + +from django.core.urlresolvers import reverse +from datetime import datetime, timedelta +from urllib.parse import urlencode +import requests +import logging +import pytz +import time + +logger = logging.getLogger(__name__) +serverRoot = "https://api.hexoskin.com/" + + +class HexoskinService(ServiceBase): + """Define the base service object""" + ID = "hexoskin" + DisplayName = "Hexoskin" + DisplayAbbreviation = "Hx" + AuthenticationType = ServiceAuthenticationType.OAuth + UserProfileURL = serverRoot + "api/account/" + UserActivityURL = serverRoot + "api/range/" + AuthenticationNoFrame = True # They don't prevent the iframe, it just looks really ugly. + LastUpload = None + + SupportsHR = SupportsCadence = SupportsTemp = True + + SupportsActivityDeletion = False + + # For mapping common->Hexoskin; no ambiguity in Hexoskin activity type + _activityTypeMappings = { + ActivityType.Cycling: "/api/activitytype/1/", + ActivityType.MountainBiking: "/api/activitytype/1/", + ActivityType.Hiking: "/api/activitytype/5/", + ActivityType.Running: "/api/activitytype/6/", + ActivityType.Walking: "/api/activitytype/15/", + ActivityType.Snowboarding: "/api/activitytype/13/", + ActivityType.Skating: "/api/activitytype/11/", + ActivityType.CrossCountrySkiing: "/api/activitytype/3/", + ActivityType.DownhillSkiing: "/api/activitytype/4/", + ActivityType.Swimming: "/api/activitytype/14/", + ActivityType.Gym: "/api/activitytype/28/", + ActivityType.Rowing: "/api/activitytype/9/", + ActivityType.Elliptical: "/api/activitytype/997/", + ActivityType.Other:"/api/activitytype/997/" + } + + + # For mapping Hexoskin->common + def _reverseActivityTypeMappings(self, key): + _reverseActivityTypeMappingsKeys = { + "/api/activitytype/1/": ActivityType.Cycling, + "/api/activitytype/3/": ActivityType.CrossCountrySkiing, + "/api/activitytype/4/": ActivityType.DownhillSkiing, + "/api/activitytype/5/": ActivityType.Hiking, + "/api/activitytype/6/": ActivityType.Running, + "/api/activitytype/7/": ActivityType.MountainBiking, + "/api/activitytype/9/": ActivityType.Rowing, + "/api/activitytype/10/": ActivityType.Running, + "/api/activitytype/11/": ActivityType.Skating, + "/api/activitytype/13/": ActivityType.Snowboarding, + "/api/activitytype/14/": ActivityType.Swimming, + "/api/activitytype/15/": ActivityType.Walking, + "/api/activitytype/24/": ActivityType.Running, + "/api/activitytype/28/": ActivityType.Gym, + } + if key in _reverseActivityTypeMappingsKeys.keys(): + return _reverseActivityTypeMappingsKeys[key] + else: + return ActivityType.Other + + SupportedActivities = list(_activityTypeMappings.keys()) + + + def UserUploadedActivityURL(self, uploadId): + return serverRoot + "api/range/%d/" % uploadId + + + def WebInit(self): + """ + prepare the oauth process request. Done separately because it needs to be + initialized on page display + """ + from uuid import uuid4 + params = {'scope':'readwrite', + 'client_id':HEXOSKIN_CLIENT_ID, + 'response_type':'code', + 'state': str(uuid4()), + 'redirect_uri':WEB_ROOT + reverse("oauth_return", kwargs={"service": "hexoskin"})} + self.UserAuthorizationURL = serverRoot + "api/connect/oauth2/auth/?" + urlencode(params) + + + def _apiHeaders(self, serviceRecord): + return {"Authorization": "Bearer " + serviceRecord.Authorization["OAuthToken"]} + + + def RetrieveAuthorizationToken(self, req, level): + """In OAuth flow, retrieve the Authorization Token""" + code = req.GET.get("code") + params = {"grant_type": "authorization_code", "code": code,"client_id":HEXOSKIN_CLIENT_ID, "client_secret": HEXOSKIN_CLIENT_SECRET, "redirect_uri": WEB_ROOT + reverse("oauth_return", kwargs={"service": "hexoskin"})} + path = serverRoot + "api/connect/oauth2/token/" + response = requests.post(path, params=params, auth=(HEXOSKIN_CLIENT_ID,HEXOSKIN_CLIENT_SECRET)) + + if response.status_code != 200: + raise APIException("Invalid code") + + data = response.json() + authorizationData = {"OAuthToken": data["access_token"]} + id_resp = requests.get(serverRoot + "api/account/", headers=self._apiHeaders(ServiceRecord({"Authorization": authorizationData}))) + return (id_resp.json()['objects'][0]['id'], authorizationData) + + + def RevokeAuthorization(self, serviceRecord): + """Delete authorization token""" + path = serverRoot + "api/oauth2token/232/" + headers = self._apiHeaders(serviceRecord) + result = requests.delete(path, headers=headers) + if result.status_code is not 204: + APIException("Revoking token was unsuccessful") + + + def _is_ride_valid(self, ride): + # Sync only top-level activities, and exclude rest, rest test and sleep + valid = ( + ride['rank'] is 0 + and ride['context']['activitytype'] is not None + and not any(y in ride['context']['activitytype'] for y in ['/8/', '/12/', '/106/']) + ) + return valid + + def DownloadActivityList(self, serviceRecord, exhaustive=False): + """ + Get list of user's activities in Hexoskin and return it to tapiriik database + """ + logger.debug('Hexoskin - starting to download activity list for user %s' % serviceRecord.ExternalID) + activities = [] + exclusions = [] + if exhaustive: + listEnd = (datetime.now() + timedelta(days=1.5) - datetime(1970,1,1)).total_seconds()*256 + listStart = (datetime(day=21, month=8, year=1985) - datetime(1970,1,1)).total_seconds()*256 # The distant past + resp = requests.get(serverRoot + "api/range/?user=%s&limit=100&rank=0&start__range=%s,%s" % (serviceRecord.ExternalID, int(listStart), int(listEnd)), headers=self._apiHeaders(serviceRecord)) + else: + listEnd = (datetime.now() + timedelta(days=1.5) - datetime(1970,1,1)).total_seconds()*256 + listStart = (datetime.now() - timedelta(days=30) - datetime(1970,1,1)).total_seconds()*256 + resp = requests.get(serverRoot + "api/range/?user=%s&limit=30&rank=0&start__range=%s,%s" % (serviceRecord.ExternalID, int(listStart), int(listEnd)), headers=self._apiHeaders(serviceRecord)) + if resp.status_code == 401: + raise APIException("No authorization to retrieve activity list", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) + try: + reqdata = resp.json()['objects'] + except ValueError: + logger.debug("Failed parsing hexoskin list response %s - %s" % (resp.status_code, resp.text)) + raise APIException("Failed parsing hexoskin list response %s - %s" % (resp.status_code, resp.text)) + for ride in reqdata: + try: + if not (ride['status'] == 'complete'): + pass # exclude that range for now, without excluding it in the future + elif self._is_ride_valid(ride): + activity = UploadedActivity() + activity.StartTime = pytz.utc.localize(datetime.fromtimestamp(ride['start']/256.0)) + activity.EndTime = pytz.utc.localize(datetime.fromtimestamp(ride['end']/256.0)) + activity.ServiceData = {"ActivityID": ride["id"]} + activity.Type = self._reverseActivityTypeMappings(ride['context']['activitytype']) + for metric in ride['metrics']: + # TODO check for IDs instead of titles + if metric['resource_uri'] == '/api/metric/17/': # Cadence + activity.Stats.RunCadence.update(ActivityStatistic(ActivityStatisticUnit.StepsPerMinute, value=metric['value'])) + if metric['resource_uri'] == '/api/metric/44/': # Heart rate Average + activity.Stats.HR.update(ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute, avg=metric["value"])) + if metric['resource_uri'] == '/api/metric/46/': # Heart rate Max + activity.Stats.HR.update(ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute, max=metric["value"])) + if metric['resource_uri'] == '/api/metric/71/': # Step count + activity.Stats.Strides.update(ActivityStatistic(ActivityStatisticUnit.Strides, value=metric["value"])) + if metric['resource_uri'] == '/api/metric/149/': # Energy kcal + activity.Stats.Energy.update(ActivityStatistic(ActivityStatisticUnit.Kilocalories, value=metric['value'])) + if metric['resource_uri'] == '/api/metric/501/': # Speed Max + activity.Stats.Speed.update(ActivityStatistic(ActivityStatisticUnit.MetersPerSecond, max=metric["value"])) + if metric['resource_uri'] == '/api/metric/502/': # Speed Avg + activity.Stats.Speed.update(ActivityStatistic(ActivityStatisticUnit.MetersPerSecond, avg=metric["value"])) + if metric['resource_uri'] == '/api/metric/2038/': # Distance + activity.Stats.Distance.update(ActivityStatistic(ActivityStatisticUnit.Meters, value=metric['value'])) + + activity.Name = ride["name"] + activity.Stationary = False + + ride_track = requests.get(serverRoot + "api/track/?range=%s" % ride['id'], headers=self._apiHeaders(serviceRecord)) + time.sleep(0.2) + activity.GPS = (True if ride_track.json()['objects'] else False) + + activity.CalculateUID() + activities.append(activity) + else: + exclusions.append(APIExcludeActivity("Unsupported activity type %s" % ride['context']['activitytype'], activity_id=ride["id"], user_exception=UserException(UserExceptionType.Other))) + except TypeError as e: + logger.debug("Failed parsing ranges url, response: %s\n%s" % (resp.url, resp.content)) + raise e + logger.debug('Hexoskin - %s activities found, %s excluded. Activities' % ([x.ServiceData['ActivityID'] for x in activities], [x.ExternalActivityID for x in exclusions])) + return activities, exclusions + + + def DownloadActivity(self, serviceRecord, activity): + """Extract activity from Hexoskin""" + activityID = activity.ServiceData["ActivityID"] + logger.debug('Hexoskin - Extracting activity %s' % activityID) + headers = self._apiHeaders(serviceRecord) + headers.update({"Accept":"application/vnd.garmin.tcx+xml"}) + range_tcx = requests.get(serverRoot + "api/range/%s/" % (str(activityID)), headers=headers) + TCXIO.Parse(range_tcx.content, activity) + activity.Notes = activity.Name + activity.Name = '%s - Hx' % (activity.Type) + return activity + + + def UploadActivity(self, serviceRecord, activity): + """Import data into Hexoskin using TCX format""" + tcx_data = TCXIO.Dump(activity) + headers = self._apiHeaders(serviceRecord) + headers.update({"Content-Type":"application/vnd.garmin.tcx+xml"}) + + range_tcx = requests.post(serverRoot + "api/import/", data=tcx_data.encode('utf-8'), headers=headers) + import_id = str(range_tcx.json()['resource_uri']) + # TODO In line below, fix the way the resource_uri is constructed when the /importfile/ fix is deployed + uploaded = False + process_start_time = datetime.now() + while uploaded is False: + time.sleep(1) + range_upload_status = requests.get(serverRoot + import_id, headers=headers).json() + if (datetime.now() - process_start_time).total_seconds() < 20: + if range_upload_status['results'] is not None and 'error' in range_upload_status['results']: + err = range_upload_status['results']['error'] + logger.debug('Hexoskin - Import range failed, see error') + raise APIException('Error uploading to Hexoskin: %s' % err) + elif range_upload_status['progress'] == 1 and range_upload_status['results']: + for entry in range_upload_status['results']: + if 'resource_uri' in entry.keys() and "range" in entry['resource_uri'] and entry['rank'] == 0: + upload_id = entry['id'] + logger.debug('Hexoskin - Imported range %s' % upload_id) + uploaded = True + break + else: + raise APIException('Timeout uploading activity for import id %s' % import_id) # when the '0' bug is fixed, change this + return upload_id + + + def DeleteCachedData(self, serviceRecord): + """No cached data""" + pass + + + def DeleteActivity(self, serviceRecord, uploadId): + """We would rather have users delete data from their dashboard instead of an automatic tool""" + pass diff --git a/tapiriik/services/__init__.py b/tapiriik/services/__init__.py index 4531cd093..e43fccae8 100644 --- a/tapiriik/services/__init__.py +++ b/tapiriik/services/__init__.py @@ -4,6 +4,8 @@ RunKeeper = RunKeeperService() from tapiriik.services.Strava import StravaService Strava = StravaService() +from tapiriik.services.Hexoskin import HexoskinService +Hexoskin = HexoskinService() from tapiriik.services.Endomondo import EndomondoService Endomondo = EndomondoService() from tapiriik.services.Dropbox import DropboxService diff --git a/tapiriik/services/service.py b/tapiriik/services/service.py index a3183d619..958643f3c 100644 --- a/tapiriik/services/service.py +++ b/tapiriik/services/service.py @@ -25,7 +25,7 @@ def FromID(id): raise ValueError def List(): - return [RunKeeper, Strava, GarminConnect, SportTracks, Dropbox, TrainingPeaks, RideWithGPS, Endomondo, Motivato, NikePlus, VeloHero, TrainerRoad, Smashrun] + PRIVATE_SERVICES + return [RunKeeper, Strava, GarminConnect, SportTracks, Dropbox, TrainingPeaks, RideWithGPS, Endomondo, Motivato, NikePlus, VeloHero, TrainerRoad, Smashrun, Hexoskin] + PRIVATE_SERVICES def PreferredDownloadPriorityList(): # Ideally, we'd make an informed decision based on whatever features the activity had @@ -44,7 +44,8 @@ def PreferredDownloadPriorityList(): Endomondo, # No laps, no cadence RunKeeper, # No laps, no cadence, no power Motivato, - NikePlus + NikePlus, + Hexoskin ] + PRIVATE_SERVICES def WebInit(): diff --git a/tapiriik/web/static/img/services/hexoskin.png b/tapiriik/web/static/img/services/hexoskin.png new file mode 100644 index 0000000000000000000000000000000000000000..6106a0ff9c28dc11473d03462f473f4fec4d4f9b GIT binary patch literal 4947 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 D Date: Mon, 7 Mar 2016 15:50:27 -0500 Subject: [PATCH 2/2] Change exported activity name, setup local_settings correctly and fix oauth hardcoded resource id --- tapiriik/local_settings.py.example | 4 +++ tapiriik/services/Hexoskin/hexoskin.py | 36 ++++++++++++-------------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/tapiriik/local_settings.py.example b/tapiriik/local_settings.py.example index a4893d4c7..c28549e4e 100644 --- a/tapiriik/local_settings.py.example +++ b/tapiriik/local_settings.py.example @@ -36,6 +36,10 @@ NIKEPLUS_CLIENT_NAME = "####" NIKEPLUS_CLIENT_ID = "####" NIKEPLUS_CLIENT_SECRET = "####" +HEXOSKIN_CLIENT_RESOURCE_ID = "####" +HEXOSKIN_CLIENT_ID = "####" +HEXOSKIN_CLIENT_SECRET = "####" + RWGPS_APIKEY = "####" # See http://api.smashrun.com for info. diff --git a/tapiriik/services/Hexoskin/hexoskin.py b/tapiriik/services/Hexoskin/hexoskin.py index d9469665b..4ed594004 100644 --- a/tapiriik/services/Hexoskin/hexoskin.py +++ b/tapiriik/services/Hexoskin/hexoskin.py @@ -1,4 +1,4 @@ -from tapiriik.settings import WEB_ROOT, HEXOSKIN_CLIENT_SECRET, HEXOSKIN_CLIENT_ID +from tapiriik.settings import WEB_ROOT, HEXOSKIN_CLIENT_SECRET, HEXOSKIN_CLIENT_ID, HEXOSKIN_CLIENT_RESOURCE_ID from tapiriik.services.service_base import ServiceAuthenticationType, ServiceBase from tapiriik.services.service_record import ServiceRecord from tapiriik.services.interchange import UploadedActivity, ActivityType, ActivityStatistic, ActivityStatisticUnit @@ -14,8 +14,6 @@ import time logger = logging.getLogger(__name__) -serverRoot = "https://api.hexoskin.com/" - class HexoskinService(ServiceBase): """Define the base service object""" @@ -23,8 +21,8 @@ class HexoskinService(ServiceBase): DisplayName = "Hexoskin" DisplayAbbreviation = "Hx" AuthenticationType = ServiceAuthenticationType.OAuth - UserProfileURL = serverRoot + "api/account/" - UserActivityURL = serverRoot + "api/range/" + UserProfileURL = "https://api.hexoskin.com/api/account/" + UserActivityURL = "https://api.hexoskin.com/api/range/" AuthenticationNoFrame = True # They don't prevent the iframe, it just looks really ugly. LastUpload = None @@ -78,7 +76,7 @@ def _reverseActivityTypeMappings(self, key): def UserUploadedActivityURL(self, uploadId): - return serverRoot + "api/range/%d/" % uploadId + return "https://api.hexoskin.com/api/range/%d/" % uploadId def WebInit(self): @@ -92,7 +90,7 @@ def WebInit(self): 'response_type':'code', 'state': str(uuid4()), 'redirect_uri':WEB_ROOT + reverse("oauth_return", kwargs={"service": "hexoskin"})} - self.UserAuthorizationURL = serverRoot + "api/connect/oauth2/auth/?" + urlencode(params) + self.UserAuthorizationURL = "https://api.hexoskin.com/api/connect/oauth2/auth/?" + urlencode(params) def _apiHeaders(self, serviceRecord): @@ -103,7 +101,7 @@ def RetrieveAuthorizationToken(self, req, level): """In OAuth flow, retrieve the Authorization Token""" code = req.GET.get("code") params = {"grant_type": "authorization_code", "code": code,"client_id":HEXOSKIN_CLIENT_ID, "client_secret": HEXOSKIN_CLIENT_SECRET, "redirect_uri": WEB_ROOT + reverse("oauth_return", kwargs={"service": "hexoskin"})} - path = serverRoot + "api/connect/oauth2/token/" + path = "https://api.hexoskin.com/api/connect/oauth2/token/" response = requests.post(path, params=params, auth=(HEXOSKIN_CLIENT_ID,HEXOSKIN_CLIENT_SECRET)) if response.status_code != 200: @@ -111,13 +109,13 @@ def RetrieveAuthorizationToken(self, req, level): data = response.json() authorizationData = {"OAuthToken": data["access_token"]} - id_resp = requests.get(serverRoot + "api/account/", headers=self._apiHeaders(ServiceRecord({"Authorization": authorizationData}))) + id_resp = requests.get("https://api.hexoskin.com/api/account/", headers=self._apiHeaders(ServiceRecord({"Authorization": authorizationData}))) return (id_resp.json()['objects'][0]['id'], authorizationData) def RevokeAuthorization(self, serviceRecord): """Delete authorization token""" - path = serverRoot + "api/oauth2token/232/" + path = "https://api.hexoskin.com/api/oauth2token/%s/" % HEXOSKIN_CLIENT_RESOURCE_ID headers = self._apiHeaders(serviceRecord) result = requests.delete(path, headers=headers) if result.status_code is not 204: @@ -143,11 +141,11 @@ def DownloadActivityList(self, serviceRecord, exhaustive=False): if exhaustive: listEnd = (datetime.now() + timedelta(days=1.5) - datetime(1970,1,1)).total_seconds()*256 listStart = (datetime(day=21, month=8, year=1985) - datetime(1970,1,1)).total_seconds()*256 # The distant past - resp = requests.get(serverRoot + "api/range/?user=%s&limit=100&rank=0&start__range=%s,%s" % (serviceRecord.ExternalID, int(listStart), int(listEnd)), headers=self._apiHeaders(serviceRecord)) + resp = requests.get("https://api.hexoskin.com/api/range/?user=%s&limit=100&rank=0&start__range=%s,%s" % (serviceRecord.ExternalID, int(listStart), int(listEnd)), headers=self._apiHeaders(serviceRecord)) else: listEnd = (datetime.now() + timedelta(days=1.5) - datetime(1970,1,1)).total_seconds()*256 listStart = (datetime.now() - timedelta(days=30) - datetime(1970,1,1)).total_seconds()*256 - resp = requests.get(serverRoot + "api/range/?user=%s&limit=30&rank=0&start__range=%s,%s" % (serviceRecord.ExternalID, int(listStart), int(listEnd)), headers=self._apiHeaders(serviceRecord)) + resp = requests.get("https://api.hexoskin.com/api/range/?user=%s&limit=30&rank=0&start__range=%s,%s" % (serviceRecord.ExternalID, int(listStart), int(listEnd)), headers=self._apiHeaders(serviceRecord)) if resp.status_code == 401: raise APIException("No authorization to retrieve activity list", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) try: @@ -186,8 +184,7 @@ def DownloadActivityList(self, serviceRecord, exhaustive=False): activity.Name = ride["name"] activity.Stationary = False - - ride_track = requests.get(serverRoot + "api/track/?range=%s" % ride['id'], headers=self._apiHeaders(serviceRecord)) + ride_track = requests.get("https://api.hexoskin.com/api/track/?range=%s" % ride['id'], headers=self._apiHeaders(serviceRecord)) time.sleep(0.2) activity.GPS = (True if ride_track.json()['objects'] else False) @@ -205,13 +202,14 @@ def DownloadActivityList(self, serviceRecord, exhaustive=False): def DownloadActivity(self, serviceRecord, activity): """Extract activity from Hexoskin""" activityID = activity.ServiceData["ActivityID"] + activityPreTCXParseName = activity.Name # Keep activity name in memory because TCX overrides activity name with somehting that's more a note than a name logger.debug('Hexoskin - Extracting activity %s' % activityID) headers = self._apiHeaders(serviceRecord) headers.update({"Accept":"application/vnd.garmin.tcx+xml"}) - range_tcx = requests.get(serverRoot + "api/range/%s/" % (str(activityID)), headers=headers) + range_tcx = requests.get("https://api.hexoskin.com/api/range/%s/" % (str(activityID)), headers=headers) TCXIO.Parse(range_tcx.content, activity) - activity.Notes = activity.Name - activity.Name = '%s - Hx' % (activity.Type) + activity.Notes = 'Hexoskin - %s' % activity.Name + activity.Name = activityPreTCXParseName return activity @@ -221,14 +219,14 @@ def UploadActivity(self, serviceRecord, activity): headers = self._apiHeaders(serviceRecord) headers.update({"Content-Type":"application/vnd.garmin.tcx+xml"}) - range_tcx = requests.post(serverRoot + "api/import/", data=tcx_data.encode('utf-8'), headers=headers) + range_tcx = requests.post("https://api.hexoskin.com/api/import/", data=tcx_data.encode('utf-8'), headers=headers) import_id = str(range_tcx.json()['resource_uri']) # TODO In line below, fix the way the resource_uri is constructed when the /importfile/ fix is deployed uploaded = False process_start_time = datetime.now() while uploaded is False: time.sleep(1) - range_upload_status = requests.get(serverRoot + import_id, headers=headers).json() + range_upload_status = requests.get("https://api.hexoskin.com/" + import_id, headers=headers).json() if (datetime.now() - process_start_time).total_seconds() < 20: if range_upload_status['results'] is not None and 'error' in range_upload_status['results']: err = range_upload_status['results']['error']