Example #1
0
    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)
Example #2
0
    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)
Example #3
0
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...
Example #4
0
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))
Example #5
0
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"])
Example #6
0
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"]