def test_activity_specificity_resolution(self): # Mountain biking is more specific than just cycling self.assertEqual( ActivityType.PickMostSpecific( [ActivityType.Cycling, ActivityType.MountainBiking]), ActivityType.MountainBiking) # But not once we mix in an unrelated activity - pick the first self.assertEqual( ActivityType.PickMostSpecific([ ActivityType.Cycling, ActivityType.MountainBiking, ActivityType.Swimming ]), ActivityType.Cycling) # Duplicates self.assertEqual( ActivityType.PickMostSpecific([ ActivityType.Cycling, ActivityType.MountainBiking, ActivityType.MountainBiking ]), ActivityType.MountainBiking) # One self.assertEqual( ActivityType.PickMostSpecific([ActivityType.MountainBiking]), ActivityType.MountainBiking) # With None self.assertEqual( ActivityType.PickMostSpecific([None, ActivityType.MountainBiking]), ActivityType.MountainBiking) # All None self.assertEqual(ActivityType.PickMostSpecific([None, None]), ActivityType.Other) # Never pick 'Other' given a better option self.assertEqual( ActivityType.PickMostSpecific( [ActivityType.Other, ActivityType.MountainBiking]), ActivityType.MountainBiking) # Normal w/ Other + None self.assertEqual( ActivityType.PickMostSpecific([ ActivityType.Other, ActivityType.Cycling, None, ActivityType.MountainBiking ]), ActivityType.MountainBiking)
def _accumulateActivities(svc, svcActivities, activityList): # Yep, abs() works on timedeltas activityStartLeeway = timedelta(minutes=3) timezoneErrorPeriod = timedelta(hours=38) from tapiriik.services.interchange import ActivityType for act in svcActivities: act.UIDs = [act.UID] if act.TZ and not hasattr(act.TZ, "localize"): raise ValueError("Got activity with TZ type " + str(type(act.TZ)) + " instead of a pytz timezone") # Used to ensureTZ() right here - doubt it's needed any more? existElsewhere = [ x for x in activityList if x.UID == act.UID or # check to see if the activities are reasonably close together to be considered duplicate (x.StartTime is not None and act.StartTime is not None and (act.StartTime.tzinfo is not None) == (x.StartTime.tzinfo is not None) and abs(act.StartTime - x.StartTime) < activityStartLeeway) or # try comparing the time as if it were TZ-aware and in the expected TZ (this won't actually change the value of the times being compared) (x.StartTime is not None and act.StartTime is not None and (act.StartTime.tzinfo is not None) != (x.StartTime.tzinfo is not None) and abs( act.StartTime.replace(tzinfo=None) - x.StartTime.replace( tzinfo=None)) < activityStartLeeway) or # Sometimes wacky stuff happens and we get two activities with the same mm:ss but different hh, because of a TZ issue somewhere along the line. # So, we check for any activities +/- 14, wait, 38 hours that have the same minutes and seconds values. # (14 hours because Kiribati, and later, 38 hours because of some really terrible import code that existed on a service that shall not be named). # There's a very low chance that two activities in this period would intersect and be merged together. # But, given the fact that most users have maybe 0.05 activities per this period, it's an acceptable tradeoff. (x.StartTime is not None and act.StartTime is not None and abs( act.StartTime.replace(tzinfo=None) - x.StartTime.replace(tzinfo=None)) < timezoneErrorPeriod and act.StartTime.replace(tzinfo=None).time().replace(hour=0) == x.StartTime.replace(tzinfo=None).time().replace(hour=0)) ] if len(existElsewhere) > 0: # we don't merge the exclude values here, since at this stage the services have the option of just not returning those activities if act.TZ is not None and existElsewhere[0].TZ is None: existElsewhere[0].TZ = act.TZ existElsewhere[0].DefineTZ() # tortuous merging logic is tortuous existElsewhere[0].StartTime = Sync._coalesceDatetime( existElsewhere[0].StartTime, act.StartTime) existElsewhere[0].EndTime = Sync._coalesceDatetime( existElsewhere[0].EndTime, act.EndTime, knownTz=existElsewhere[0].StartTime.tzinfo) existElsewhere[0].Name = existElsewhere[ 0].Name if existElsewhere[0].Name is not None else act.Name existElsewhere[ 0].Waypoints = existElsewhere[0].Waypoints if len( existElsewhere[0].Waypoints) > 0 else act.Waypoints existElsewhere[0].Type = ActivityType.PickMostSpecific( [existElsewhere[0].Type, act.Type]) existElsewhere[ 0].Private = existElsewhere[0].Private or act.Private prerenderedFormats = act.PrerenderedFormats prerenderedFormats.update(existElsewhere[0].PrerenderedFormats) existElsewhere[ 0].PrerenderedFormats = prerenderedFormats # I bet this is gonna kill the RAM usage. existElsewhere[0].UploadedTo += act.UploadedTo existElsewhere[0].UIDs += act.UIDs # I think this is merited act.UIDs = existElsewhere[ 0].UIDs # stop the circular inclusion, not that it matters continue activityList.append(act)
class TrainAsONEService(ServiceBase): # XXX need to normalise API paths - some url contains additional /api as direct to main server ID = "trainasone" DisplayName = "TrainAsONE" DisplayAbbreviation = "TAO" AuthenticationType = ServiceAuthenticationType.OAuth AuthenticationNoFrame = True # iframe too small LastUpload = None SupportsHR = SupportsCadence = SupportsTemp = SupportsPower = True SupportsActivityDeletion = False SupportedActivities = ActivityType.List() # All def UserUploadedActivityURL(self, uploadId): raise NotImplementedError # XXX need to include user id # return TRAINASONE_SERVER_URL + "/activities/view?targetUserId=%s&activityId=%s" % uploadId def WebInit(self): params = { 'scope': 'SYNCHRONIZE_ACTIVITIES', 'client_id': TRAINASONE_CLIENT_ID, 'response_type': 'code', 'redirect_uri': WEB_ROOT + reverse("oauth_return", kwargs={"service": "trainasone"}) } self.UserAuthorizationURL = TRAINASONE_SERVER_URL + "/oauth/authorise?" + urlencode( params) def _apiHeaders(self, authorization): return {"Authorization": "Bearer " + authorization["OAuthToken"]} def RetrieveAuthorizationToken(self, req, level): code = req.GET.get("code") params = { "grant_type": "authorization_code", "code": code, "client_id": TRAINASONE_CLIENT_ID, "client_secret": TRAINASONE_CLIENT_SECRET, "redirect_uri": WEB_ROOT + reverse("oauth_return", kwargs={"service": "trainasone"}) } response = requests.post(TRAINASONE_SERVER_URL + "/oauth/token", data=params) if response.status_code != 200: raise APIException("Invalid code") data = response.json() authorizationData = {"OAuthToken": data["access_token"]} id_resp = requests.get(TRAINASONE_SERVER_URL + "/api/sync/user", headers=self._apiHeaders(authorizationData)) return (id_resp.json()["id"], authorizationData) def RevokeAuthorization(self, serviceRecord): resp = requests.post( TRAINASONE_SERVER_URL + "/api/oauth/revoke", data={"token": serviceRecord.Authorization["OAuthToken"]}, headers=self._apiHeaders(serviceRecord.Authorization)) if resp.status_code != 204 and resp.status_code != 200: raise APIException( "Unable to deauthorize TAO auth token, status " + str(resp.status_code) + " resp " + resp.text) pass def DownloadActivityList(self, serviceRecord, exhaustive=False): allItems = [] pageUri = TRAINASONE_SERVER_URL + "/api/sync/activities" while True: response = requests.get(pageUri, headers=self._apiHeaders( serviceRecord.Authorization)) if response.status_code != 200: if response.status_code == 401 or response.status_code == 403: raise APIException( "No authorization to retrieve activity list", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) raise APIException("Unable to retrieve activity list " + str(response) + " " + response.text) data = response.json() allItems += data["activities"] if not exhaustive or "next" not in data or data["next"] is None: break pageUri = TRAINASONE_SERVER_URL + data["next"] activities = [] exclusions = [] for act in allItems: try: activity = self._populateActivity(act) except KeyError as e: exclusions.append( APIExcludeActivity("Missing key in activity data " + str(e), activity_id=act["activityId"], user_exception=UserException( UserExceptionType.Corrupt))) continue logger.debug("\tActivity s/t " + str(activity.StartTime)) activity.ServiceData = {"id": act["activityId"]} activities.append(activity) return activities, exclusions def _populateActivity(self, rawRecord): ''' Populate the 1st level of the activity object with all details required for UID from API data ''' activity = UploadedActivity() activity.StartTime = datetime.fromtimestamp(rawRecord["start"] / 1000) activity.EndTime = activity.StartTime + timedelta( seconds=rawRecord["duration"]) activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Meters, value=rawRecord["distance"]) activity.GPS = rawRecord["hasGps"] activity.Stationary = not rawRecord["hasGps"] activity.CalculateUID() return activity def DownloadActivity(self, serviceRecord, activity): activity_id = activity.ServiceData["id"] resp = requests.get( TRAINASONE_SERVER_URL + "/api/sync/activity/tcx/" + activity_id, headers=self._apiHeaders(serviceRecord.Authorization)) try: TCXIO.Parse(resp.content, activity) except ValueError as e: raise APIExcludeActivity("TCX parse error " + str(e), user_exception=UserException( UserExceptionType.Corrupt)) return activity def UploadActivity(self, serviceRecord, activity): # Upload the workout as a .TCX file uploaddata = TCXIO.Dump(activity) headers = self._apiHeaders(serviceRecord.Authorization) headers['Content-Type'] = 'application/xml' resp = requests.post(TRAINASONE_SERVER_URL + "/api/sync/activity/tcx", data=uploaddata, headers=headers) if resp.status_code != 200: raise APIException("Error uploading activity - " + str(resp.status_code), block=False) responseJson = resp.json() if not responseJson["id"]: raise APIException("Error uploading activity - " + resp.Message, block=False) activityId = responseJson["id"] return activityId def DeleteCachedData(self, serviceRecord): pass # No cached data...
class TrainingPeaksService(ServiceBase): ID = "trainingpeaks" DisplayName = "TrainingPeaks" DisplayAbbreviation = "TP" AuthenticationType = ServiceAuthenticationType.UsernamePassword RequiresExtendedAuthorizationDetails = True ReceivesStationaryActivities = False SupportsHR = SupportsCadence = SupportsTemp = SupportsPower = True # Not-so-coincidentally, similar to PWX. _workoutTypeMappings = { "Bike": ActivityType.Cycling, "Run": ActivityType.Running, "Walk": ActivityType.Walking, "Swim": ActivityType.Swimming, "MTB": ActivityType.MountainBiking, "XC-Ski": ActivityType.CrossCountrySkiing, "Rowing": ActivityType.Rowing, "X-Train": ActivityType.Other, "Strength": ActivityType.Other, "Race": ActivityType.Other, "Custom": ActivityType.Other, "Other": ActivityType.Other, } SupportedActivities = ActivityType.List() # All. _tp_ns = { "tpw": "http://www.trainingpeaks.com/TPWebServices/", "xsi": "http://www.w3.org/2001/XMLSchema-instance" } def WebInit(self): self.UserAuthorizationURL = WEB_ROOT + reverse( "auth_simple", kwargs={"service": self.ID}) def _authData(self, serviceRecord): from tapiriik.auth.credential_storage import CredentialStore password = CredentialStore.Decrypt( serviceRecord.ExtendedAuthorization["Password"]) username = CredentialStore.Decrypt( serviceRecord.ExtendedAuthorization["Username"]) return {"username": username, "password": password} def Authorize(self, email, password): from tapiriik.auth.credential_storage import CredentialStore soap_auth_data = {"username": email, "password": password} resp = requests.post( "https://www.trainingpeaks.com/tpwebservices/service.asmx/AuthenticateAccount", data=soap_auth_data) if resp.status_code != 200: raise APIException("Invalid login") soap_auth_data.update({ "types": "CoachedPremium,SelfCoachedPremium,SharedCoachedPremium,CoachedFree,SharedFree,Plan" }) users_resp = requests.post( "https://www.trainingpeaks.com/tpwebservices/service.asmx/GetAccessibleAthletes", data=soap_auth_data) users_resp = etree.XML(users_resp.content) personId = None for xperson in users_resp: xpersonid = xperson.find("tpw:PersonId", namespaces=self._tp_ns) if xpersonid is not None and xpersonid.text: personId = int(xpersonid.text) break # Yes, I have it on good authority that this is checked further on on the remote end. if not personId: raise APIException("Account not premium", block=True, user_exception=UserException( UserExceptionType.AccountUnpaid, intervention_required=True, extra=personId)) return (personId, {}, { "Username": CredentialStore.Encrypt(email), "Password": CredentialStore.Encrypt(password) }) def RevokeAuthorization(self, serviceRecord): pass # No auth tokens to revoke... def DeleteCachedData(self, serviceRecord): pass # No cached data... def DownloadActivityList(self, svcRecord, exhaustive=False): ns = self._tp_ns activities = [] exclusions = [] reqData = self._authData(svcRecord) limitDateFormat = "%d %B %Y" if exhaustive: listEnd = datetime.now() + timedelta( days=1.5) # Who knows which TZ it's in listStart = datetime(day=1, month=1, year=1980) # The beginning of time else: listEnd = datetime.now() + timedelta( days=1.5) # Who knows which TZ it's in listStart = listEnd - timedelta(days=20) # Doesn't really matter lastActivityDay = None discoveredWorkoutIds = [] while True: reqData.update({ "startDate": listStart.strftime(limitDateFormat), "endDate": listEnd.strftime(limitDateFormat) }) print("Requesting %s to %s" % (listStart, listEnd)) resp = requests.post( "https://www.trainingpeaks.com/tpwebservices/service.asmx/GetWorkoutsForAthlete", data=reqData) xresp = etree.XML(resp.content) for xworkout in xresp: activity = UploadedActivity() workoutId = xworkout.find("tpw:WorkoutId", namespaces=ns).text workoutDayEl = xworkout.find("tpw:WorkoutDay", namespaces=ns) startTimeEl = xworkout.find("tpw:StartTime", namespaces=ns) workoutDay = dateutil.parser.parse(workoutDayEl.text) startTime = dateutil.parser.parse( startTimeEl.text ) if startTimeEl is not None and startTimeEl.text else None if lastActivityDay is None or workoutDay.replace( tzinfo=None) > lastActivityDay: lastActivityDay = workoutDay.replace(tzinfo=None) if startTime is None: continue # Planned but not executed yet. activity.StartTime = startTime endTimeEl = xworkout.find("tpw:TimeTotalInSeconds", namespaces=ns) if not endTimeEl.text: exclusions.append( APIExcludeActivity("Activity has no duration", activity_id=workoutId, user_exception=UserException( UserExceptionType.Corrupt))) continue activity.EndTime = activity.StartTime + timedelta( seconds=float(endTimeEl.text)) distEl = xworkout.find("tpw:DistanceInMeters", namespaces=ns) if distEl.text: activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Meters, value=float(distEl.text)) # PWX is damn near comprehensive, no need to fill in any of the other statisitcs here, really if workoutId in discoveredWorkoutIds: continue # There's the possibility of query overlap, if there are multiple activities on a single day that fall across the query return limit discoveredWorkoutIds.append(workoutId) workoutTypeEl = xworkout.find("tpw:WorkoutTypeDescription", namespaces=ns) if workoutTypeEl.text: if workoutTypeEl.text == "Day Off": continue # TrainingPeaks has some weird activity types... if workoutTypeEl.text not in self._workoutTypeMappings: exclusions.append( APIExcludeActivity("Activity type %s unknown" % workoutTypeEl.text, activity_id=workoutId, user_exception=UserException( UserExceptionType.Corrupt))) continue activity.Type = self._workoutTypeMappings[ workoutTypeEl.text] activity.ServiceData = {"WorkoutID": workoutId} activity.CalculateUID() activities.append(activity) if not exhaustive: break # Since TP only lets us query by date range, to get full activity history we need to query successively smaller ranges if len(xresp): if listStart == lastActivityDay: break # This wouldn't work if you had more than #MaxQueryReturn activities on that day - but that number is probably 50+ listStart = lastActivityDay else: break # We're done return activities, exclusions def DownloadActivity(self, svcRecord, activity): params = self._authData(svcRecord) params.update({ "workoutIds": activity.ServiceData["WorkoutID"], "personId": svcRecord.ExternalID }) resp = requests.get( "https://www.trainingpeaks.com/tpwebservices/service.asmx/GetExtendedWorkoutsForAccessibleAthlete", params=params) activity = PWXIO.Parse(resp.content, activity) activity.GPS = False flat_wps = activity.GetFlatWaypoints() for wp in flat_wps: if wp.Location and wp.Location.Latitude and wp.Location.Longitude: activity.GPS = True break return activity def UploadActivity(self, svcRecord, activity): pwxdata = PWXIO.Dump(activity) params = self._authData(svcRecord) resp = requests.post( "https://www.trainingpeaks.com/TPWebServices/EasyFileUpload.ashx", params=params, data=pwxdata.encode("UTF-8")) if resp.text != "OK": raise APIException("Unable to upload activity response " + resp.text + " status " + str(resp.status_code))
class VeloHeroService(ServiceBase): ID = "velohero" DisplayName = "Velo Hero" DisplayAbbreviation = "VH" _urlRoot = "http://app.velohero.com" AuthenticationType = ServiceAuthenticationType.UsernamePassword RequiresExtendedAuthorizationDetails = True ReceivesStationaryActivities = False SupportsHR = SupportsCadence = SupportsTemp = SupportsPower = True SupportedActivities = ActivityType.List() # All. # http://app.velohero.com/sports/list?view=json _reverseActivityMappings = { 1: ActivityType.Cycling, 2: ActivityType.Running, 3: ActivityType.Swimming, 4: ActivityType.Gym, 5: ActivityType.Other, # Strength 6: ActivityType.MountainBiking, 7: ActivityType.Hiking, 8: ActivityType.CrossCountrySkiing, # Currently not in use. Reserved for future use. 9: ActivityType.Other, 10: ActivityType.Other, 11: ActivityType.Other, 12: ActivityType.Other, 13: ActivityType.Other, 14: ActivityType.Other, 15: ActivityType.Other, } def _add_auth_params(self, params=None, record=None): """ Adds username and password to the passed-in params, returns modified params dict. """ from tapiriik.auth.credential_storage import CredentialStore if params is None: params = {} if record: email = CredentialStore.Decrypt( record.ExtendedAuthorization["Email"]) password = CredentialStore.Decrypt( record.ExtendedAuthorization["Password"]) params['user'] = email params['pass'] = password return params def WebInit(self): self.UserAuthorizationURL = WEB_ROOT + reverse( "auth_simple", kwargs={"service": self.ID}) def Authorize(self, email, password): """ POST Username and Password URL: http://app.velohero.com/sso Parameters: user = username pass = password view = json The login was successful if you get HTTP status code 200. For other HTTP status codes, the login was not successful. """ from tapiriik.auth.credential_storage import CredentialStore res = requests.post(self._urlRoot + "/sso", params={ 'user': email, 'pass': password, 'view': 'json' }) if res.status_code != 200: raise APIException("Invalid login", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) res.raise_for_status() res = res.json() if res["session"] is None: raise APIException("Invalid login", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) member_id = res["user-id"] if not member_id: raise APIException("Unable to retrieve user id", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) return (member_id, {}, { "Email": CredentialStore.Encrypt(email), "Password": CredentialStore.Encrypt(password) }) def RevokeAuthorization(self, serviceRecord): pass # No auth tokens to revoke... def DeleteCachedData(self, serviceRecord): pass # No cached data... def _parseDateTime(self, date): return datetime.strptime(date, "%Y-%m-%d %H:%M:%S") def _durationToSeconds(self, dur): parts = dur.split(":") return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2]) def DownloadActivityList(self, serviceRecord, exhaustive=False): """ GET List of Activities as JSON File URL: http://app.velohero.com/export/workouts/json Parameters: user = username pass = password date_from = YYYY-MM-DD date_to = YYYY-MM-DD """ activities = [] exclusions = [] discoveredWorkoutIds = [] params = self._add_auth_params({}, record=serviceRecord) limitDateFormat = "%Y-%m-%d" if exhaustive: listEnd = datetime.now() + timedelta( days=1.5) # Who knows which TZ it's in listStart = datetime(day=1, month=1, year=1980) # The beginning of time else: listEnd = datetime.now() + timedelta( days=1.5) # Who knows which TZ it's in listStart = listEnd - timedelta(days=20) # Doesn't really matter params.update({ "date_from": listStart.strftime(limitDateFormat), "date_to": listEnd.strftime(limitDateFormat) }) logger.debug("Requesting %s to %s" % (listStart, listEnd)) res = requests.get(self._urlRoot + "/export/workouts/json", params=params) if res.status_code != 200: if res.status_code == 403: raise APIException("Invalid login", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) raise APIException("Unable to retrieve activity list") res.raise_for_status() try: res = res.json() except ValueError: raise APIException("Could not decode activity list") if "workouts" not in res: raise APIException("No activities") for workout in res["workouts"]: workoutId = int(workout["id"]) if workoutId in discoveredWorkoutIds: continue # There's the possibility of query overlap discoveredWorkoutIds.append(workoutId) if workout["file"] is not "1": logger.debug("Skip workout with ID: " + str(workoutId) + " (no file)") continue # Skip activity without samples (no PWX export) activity = UploadedActivity() logger.debug("Workout ID: " + str(workoutId)) # Duration (dur_time) duration = self._durationToSeconds(workout["dur_time"]) activity.Stats.TimerTime = ActivityStatistic( ActivityStatisticUnit.Seconds, value=duration) # Start time (date_ymd, start_time) startTimeStr = workout["date_ymd"] + " " + workout["start_time"] activity.StartTime = self._parseDateTime(startTimeStr) # End time (date_ymd, start_time) + dur_time activity.EndTime = self._parseDateTime(startTimeStr) + timedelta( seconds=duration) # Sport (sport_id) if workout["sport_id"] is not "0": activity.Type = self._reverseActivityMappings[int( workout["sport_id"])] else: activity.Type = ActivityType.Cycling # Distance (dist_km) activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Kilometers, value=float(workout["dist_km"])) # Workout is hidden activity.Private = workout["hide"] == "1" activity.ServiceData = {"workoutId": workoutId} activity.CalculateUID() activities.append(activity) return activities, exclusions def DownloadActivity(self, serviceRecord, activity): """ GET Activity as a PWX File URL: http://app.velohero.com/export/activity/pwx/<WORKOUT-ID> Parameters: user = username pass = password PWX export with laps. """ workoutId = activity.ServiceData["workoutId"] logger.debug("Download PWX export with ID: " + str(workoutId)) params = self._add_auth_params({}, record=serviceRecord) res = requests.get(self._urlRoot + "/export/activity/pwx/{}".format(workoutId), params=params) if res.status_code != 200: if res.status_code == 403: raise APIException( "No authorization to download activity with workout ID: {}" .format(workoutId), block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) raise APIException( "Unable to download activity with workout ID: {}".format( workoutId)) activity = PWXIO.Parse(res.content, activity) return activity def UploadActivity(self, serviceRecord, activity): """ POST a Multipart-Encoded File URL: http://app.velohero.com/upload/file Parameters: user = username pass = password view = json file = multipart-encodes file (fit, tcx, pwx, gpx, srm, hrm...) Maximum file size per file is 16 MB. """ fit_file = FITIO.Dump(activity) files = { "file": ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + ".fit", fit_file) } params = self._add_auth_params({"view": "json"}, record=serviceRecord) res = requests.post(self._urlRoot + "/upload/file", files=files, params=params) if res.status_code != 200: if res.status_code == 403: raise APIException("Invalid login", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) raise APIException("Unable to upload activity") res.raise_for_status() res = res.json() if "error" in res: raise APIException(res["error"])
class TrainingPeaksService(ServiceBase): ID = "trainingpeaks" DisplayName = "TrainingPeaks" DisplayAbbreviation = "TP" AuthenticationType = ServiceAuthenticationType.OAuth RequiresExtendedAuthorizationDetails = False ReceivesStationaryActivities = False SuppliesActivities = False AuthenticationNoFrame = True SupportsExhaustiveListing = False SupportsHR = SupportsCadence = SupportsTemp = SupportsPower = True # Not-so-coincidentally, similar to PWX. _workoutTypeMappings = { "bike": ActivityType.Cycling, "run": ActivityType.Running, "walk": ActivityType.Walking, "swim": ActivityType.Swimming, "mtb": ActivityType.MountainBiking, "xc-Ski": ActivityType.CrossCountrySkiing, "rowing": ActivityType.Rowing, "x-train": ActivityType.Other, "strength": ActivityType.Other, "other": ActivityType.Other, } SupportedActivities = ActivityType.List() # All. _redirect_url = "https://www.siiink.com/auth/return/trainingpeaks" _tokenCache = SessionCache("trainingpeaks", lifetime=timedelta(minutes=30), freshen_on_get=False) def WebInit(self): self.UserAuthorizationURL = TRAININGPEAKS_OAUTH_BASE_URL + "/oauth/authorize?" + urlencode( { "client_id": TRAININGPEAKS_CLIENT_ID, "response_type": "code", "redirect_uri": self._redirect_url, "scope": TRAININGPEAKS_CLIENT_SCOPE }) def RetrieveAuthorizationToken(self, req, level): code = req.GET.get("code") params = { "client_id": TRAININGPEAKS_CLIENT_ID, "client_secret": TRAININGPEAKS_CLIENT_SECRET, "grant_type": "authorization_code", "code": code, "redirect_uri": self._redirect_url } req_url = TRAININGPEAKS_OAUTH_BASE_URL + "/oauth/token" response = requests.post(req_url, data=params) if response.status_code != 200: raise APIException("Invalid code") auth_data = response.json() profile_data = requests.get(TRAININGPEAKS_API_BASE_URL + "/v1/athlete/profile", headers={ "Authorization": "Bearer %s" % auth_data["access_token"] }).json() if type(profile_data) is list and any("is not a valid athlete" in x for x in profile_data): raise APIException("TP user is coach account", block=True, user_exception=UserException( UserExceptionType.NonAthleteAccount, intervention_required=True)) return (profile_data["Id"], { "RefreshToken": auth_data["refresh_token"] }) def _apiHeaders(self, serviceRecord): # The old API was username/password, and the new API provides no means to automatically upgrade these credentials. if not serviceRecord.Authorization or "RefreshToken" not in serviceRecord.Authorization: raise APIException("TP user lacks OAuth credentials", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) token = self._tokenCache.Get(serviceRecord.ExternalID) if not token: # Use refresh token to get access token # Hardcoded return URI to get around the lack of URL reversing without loading up all the Django stuff params = { "client_id": TRAININGPEAKS_CLIENT_ID, "client_secret": TRAININGPEAKS_CLIENT_SECRET, "grant_type": "refresh_token", "refresh_token": serviceRecord.Authorization["RefreshToken"], # "redirect_uri": self._redirect_url } headers = {"Content-Type": "application/x-www-form-urlencoded"} response = requests.post(TRAININGPEAKS_OAUTH_BASE_URL + "/oauth/token", data=urlencode(params), headers=headers) if response.status_code != 200: if response.status_code >= 400 and response.status_code < 500: raise APIException( "Could not retrieve refreshed token %s %s" % (response.status_code, response.text), block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) raise APIException("Could not retrieve refreshed token %s %s" % (response.status_code, response.text)) token = response.json()["access_token"] self._tokenCache.Set(serviceRecord.ExternalID, token) return {"Authorization": "Bearer %s" % token} def RevokeAuthorization(self, serviceRecord): pass # No auth tokens to revoke... def DeleteCachedData(self, serviceRecord): pass # No cached data... def DownloadActivityList(self, svcRecord, exhaustive_start_time=None): activities = [] exclusions = [] headers = self._apiHeaders(svcRecord) limitDateFormat = "%Y-%m-%d" if exhaustive_start_time: totalListEnd = datetime.now() + timedelta( days=1.5) # Who knows which TZ it's in totalListStart = exhaustive_start_time - timedelta(days=1.5) else: totalListEnd = datetime.now() + timedelta( days=1.5) # Who knows which TZ it's in totalListStart = totalListEnd - timedelta( days=20) # Doesn't really matter listStep = timedelta(days=45) listEnd = totalListEnd listStart = max(totalListStart, totalListEnd - listStep) while True: logger.debug("Requesting %s to %s" % (listStart, listEnd)) resp = requests.get(TRAININGPEAKS_API_BASE_URL + "/v1/workouts/%s/%s" % (listStart.strftime(limitDateFormat), listEnd.strftime(limitDateFormat)), headers=headers) for act in resp.json(): if not act.get("completed", True): continue activity = UploadedActivity() activity.StartTime = dateutil.parser.parse( act["StartTime"]).replace(tzinfo=None) logger.debug("Activity s/t " + str(activity.StartTime)) activity.EndTime = activity.StartTime + timedelta( hours=act["TotalTime"]) activity.Name = act.get("Title", None) activity.Notes = act.get("Description", None) activity.Type = self._workoutTypeMappings.get( act.get("WorkoutType", "").lower(), ActivityType.Other) activity.Stats.Cadence = ActivityStatistic( ActivityStatisticUnit.RevolutionsPerMinute, avg=act.get("CadenceAverage", None), max=act.get("CadenceMaximum", None)) activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Meters, value=act.get("Distance", None)) activity.Stats.Elevation = ActivityStatistic( ActivityStatisticUnit.Meters, avg=act.get("ElevationAverage", None), min=act.get("ElevationMinimum", None), max=act.get("ElevationMaximum", None), gain=act.get("ElevationGain", None), loss=act.get("ElevationLoss", None)) activity.Stats.Energy = ActivityStatistic( ActivityStatisticUnit.Kilojoules, value=act.get("Energy", None)) activity.Stats.HR = ActivityStatistic( ActivityStatisticUnit.BeatsPerMinute, avg=act.get("HeartRateAverage", None), min=act.get("HeartRateMinimum", None), max=act.get("HeartRateMaximum", None)) activity.Stats.Power = ActivityStatistic( ActivityStatisticUnit.Watts, avg=act.get("PowerAverage", None), max=act.get("PowerMaximum", None)) activity.Stats.Temperature = ActivityStatistic( ActivityStatisticUnit.DegreesCelcius, avg=act.get("TemperatureAverage", None), min=act.get("TemperatureMinimum", None), max=act.get("TemperatureMaximum", None)) activity.Stats.Speed = ActivityStatistic( ActivityStatisticUnit.MetersPerSecond, avg=act.get("VelocityAverage", None), max=act.get("VelocityMaximum", None)) activity.CalculateUID() activities.append(activity) if not exhaustive_start_time: break listStart -= listStep listEnd -= listStep if listEnd < totalListStart: break return activities, exclusions def UploadActivity(self, svcRecord, activity): pwxdata_gz = BytesIO() with gzip.GzipFile(fileobj=pwxdata_gz, mode="w") as gzf: gzf.write(PWXIO.Dump(activity).encode("utf-8")) headers = self._apiHeaders(svcRecord) headers.update({"Content-Type": "application/json"}) data = { "UploadClient": "tapiriik", "Filename": "tap-%s.pwx" % activity.UID, "SetWorkoutPublic": not activity.Private, # NB activity notes and name are in the PWX. "Data": base64.b64encode(pwxdata_gz.getvalue()).decode("ascii") } resp = requests.post(TRAININGPEAKS_API_BASE_URL + "/v1/file", data=json.dumps(data), headers=headers) if resp.status_code != 200: raise APIException("Unable to upload activity response " + resp.text + " status " + str(resp.status_code)) return resp.json()[0]["Id"]