Index: ow/fit.py
===================================================================
--- ow/fit.py	(revision 53bb3e54424d0f8bd311b9f034cc74fcf64d1889)
+++ ow/fit.py	(revision 53bb3e54424d0f8bd311b9f034cc74fcf64d1889)
@@ -0,0 +1,218 @@
+from fitparse import FitFile
+import gpxpy
+import gpxpy.gpx
+
+try:
+    import lxml.etree as mod_etree  # Load LXML or fallback to cET or ET
+except:
+    try:
+        import xml.etree.cElementTree as mod_etree
+    except:
+        import xml.etree.ElementTree as mod_etree
+
+from ow.utilities import semicircles_to_degrees
+
+
+class Fit(object):
+    """
+    A fit object, that can be used to load, parse, convert, etc ANT fit
+    files
+    """
+    def __init__(self, path):
+        self.path = path
+        # FitFile object containing all data after opening the file with
+        # python-fitparse
+        self.obj = FitFile(path)
+        # data is a temporary place to store info we got from the fit file,
+        # so we don't have to loop over data in self.obj multiple times
+        self.data = {}
+
+    def load(self):
+        """
+        Load contents from the fit file
+        """
+        # get some basic info from the fit file
+        #
+        # IMPORTANT: only single-session fit files are supported right now
+        sessions = list(self.obj.get_messages('session'))
+        session = sessions[0]
+        values = session.get_values()
+        self.data['sport'] = values.get('sport', None)
+
+        # in some garmin devices (520), you can have named profiles, which
+        # can be used for different bikes or for different types of workouts
+        # (training/races/etc)
+        self.data['profile'] = values.get('unknown_110', None)
+
+        # naive datetime object
+        self.data['start'] = values.get('start_time', None)
+
+        # seconds
+        self.data['duration'] = values.get('total_timer_time', None)
+        self.data['elapsed'] = values.get('total_elapsed_time', None)
+
+        # meters
+        self.data['distance'] = values.get('total_distance', None)
+
+        # meters
+        self.data['uphill'] = values.get('total_ascent', None)
+        self.data['downhill'] = values.get('total_descent', None)
+
+        self.data['calories'] = values.get('total_calories', None)
+
+        # store here a list with all hr values
+        self.data['hr'] = []
+        self.data['min_hr'] = None
+        self.data['max_hr'] = values.get('max_heart_rate', None)
+        self.data['avg_hr'] = values.get('avg_heart_rate', None)
+
+        # store here a list with all cad values
+        self.data['cad'] = []
+        self.data['min_cad'] = None
+        self.data['max_cad'] = values.get('max_cadence', None)
+        self.data['avg_cad'] = values.get('avg_cadence', None)
+
+        self.data['max_speed'] = values.get('enhanced_max_speed', None)
+        if self.data['max_speed'] is None:
+            self.data['max_speed'] = values.get('max_speed', None)
+            if self.data['max_speed'] is not None:
+                self.data['max_speed'] = self.data['max_speed'] / 1000
+
+        self.data['avg_speed'] = values.get('enhanced_avg_speed', None)
+        if self.data['avg_speed'] is None:
+            self.data['avg_speed'] = values.get('avg_speed', None)
+            if self.data['avg_speed'] is not None:
+                self.data['avg_speed'] = self.data['avg_speed'] / 1000
+
+        # no temp values yet
+        # store here a list with all temp values
+        self.data['atemp'] = []
+        self.data['min_atemp'] = None
+        self.data['max_atemp'] = 0
+        self.data['avg_atemp'] = 0
+
+    @property
+    def name(self):
+        """
+        Build a name based on some info from the fit file
+        """
+        if self.data['profile']:
+            return self.data['profile'] + ' ' + self.data['sport']
+        return self.data['sport']
+
+    def _calculate_avg_atemp(self):
+        temps = [t[0] for t in self.data['atemp']]
+        if temps:
+            self.data['avg_atemp'] = int(round(sum(temps)/len(temps)))
+        return self.data['avg_atemp']
+
+    @property
+    def gpx(self):
+        """
+        Return valid xml-formatted gpx contents from the loaded fit file
+        """
+        # Create a gpx object, adding some basic metadata to it.
+        #
+        # The schema namespaces info is very important, in order to support
+        # all the data we will extract from the fit file.
+        gpx = gpxpy.gpx.GPX()
+        gpx.creator = 'OpenWorkouts'
+        gpx.schema_locations = [
+            'http://www.topografix.com/GPX/1/1',
+            'http://www.topografix.com/GPX/1/1/gpx.xsd',
+            'http://www.garmin.com/xmlschemas/GpxExtensions/v3',
+            'http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd',
+            'http://www.garmin.com/xmlschemas/TrackPointExtension/v1',
+            'http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd'
+        ]
+        garmin_schemas = 'http://www.garmin.com/xmlschemas'
+        gpx.nsmap['gpxtpx'] = garmin_schemas + '/TrackPointExtension/v1'
+        gpx.nsmap['gpxx'] = garmin_schemas + '/GpxExtensions/v3'
+
+        # Create first track in our GPX:
+        gpx_track = gpxpy.gpx.GPXTrack()
+        gpx_track.name = self.name
+        gpx.tracks.append(gpx_track)
+
+        # Create first segment in our GPX track:
+        gpx_segment = gpxpy.gpx.GPXTrackSegment()
+        gpx_track.segments.append(gpx_segment)
+
+        # namespace for the additional gpx extensions
+        # (temperature, cadence, heart rate)
+        namespace = '{gpxtpx}'
+        # nsmap = {'ext': namespace[1:-1]}
+
+        # loop over the 'record' messages in the fit file, which are the actual
+        # data "recorded" by the device
+        for message in self.obj.get_messages('record'):
+            values = message.get_values()
+
+            # Create an entry where we add the "gpx extensions" data, that will
+            # be added to the track point later.
+            extensions_root = mod_etree.Element(
+                namespace + 'TrackPointExtension')
+
+            atemp = mod_etree.Element(namespace + 'atemp')
+            atemp_value = values.get('temperature', 0)
+            atemp.text = str(atemp_value)
+            atemp.tail = ''
+            extensions_root.append(atemp)
+            self.data['atemp'].append((atemp_value, values['timestamp']))
+            if self.data['min_atemp'] is None:
+                self.data['min_atemp'] = atemp_value
+            elif atemp_value < self.data['min_atemp']:
+                self.data['min_atemp'] = atemp_value
+            if atemp_value > self.data['max_atemp']:
+                self.data['max_atemp'] = atemp_value
+
+            hr = mod_etree.Element(namespace + 'hr')
+            hr_value = values.get('heart_rate', 0)
+            if hr_value == 0:
+                # don't allow hr values of 0, something may have gone wrong
+                # with the heart rate monitor, so we use the previous value,
+                # if any
+                if self.data['hr']:
+                    hr_value = self.data['hr'][-1][0]
+            hr.text = str(hr_value)
+            hr.tail = ''
+            extensions_root.append(hr)
+            self.data['hr'].append((hr_value, values['timestamp']))
+            # min_hr None means we are on the first value, set it as min, we
+            # don't want 0 values here. If it is not None, check if the current
+            # value is lower than the current min, update accordingly
+            if self.data['min_hr'] is None or hr_value < self.data['min_hr']:
+                self.data['min_hr'] = hr_value
+
+            cad = mod_etree.Element(namespace + 'cad')
+            cad_value = values.get('cadence', 0)
+            cad.text = str(cad_value)
+            cad.tail = ''
+            extensions_root.append(cad)
+            self.data['cad'].append((cad_value, values['timestamp']))
+            if self.data['min_cad'] is None:
+                self.data['min_cad'] = cad_value
+            elif cad_value < self.data['min_cad']:
+                self.data['min_cad'] = cad_value
+
+            # Create a gpx track point, that holds the "recorded" geo and speed
+            # data, as well as the "gpx extensions" object
+            if values.get('position_lat') and values.get('position_long'):
+                track_point = gpxpy.gpx.GPXTrackPoint(
+                    latitude=semicircles_to_degrees(values['position_lat']),
+                    longitude=semicircles_to_degrees(values['position_long']),
+                    elevation=values['enhanced_altitude'],
+                    speed=values['enhanced_speed'],
+                    time=values['timestamp']
+                )
+
+            track_point.extensions.append(extensions_root)
+
+            gpx_segment.points.append(track_point)
+
+        # if the fit file has temperature data, calculate the avg
+        if self.data['atemp']:
+            self._calculate_avg_atemp()
+
+        # return a string containing the xml representation of the gpx object
+        return gpx.to_xml()
Index: ow/models/workout.py
===================================================================
--- ow/models/workout.py	(revision e3d7b13eff7b6857b89e8250f1d28469acaa11fc)
+++ ow/models/workout.py	(revision 53bb3e54424d0f8bd311b9f034cc74fcf64d1889)
@@ -7,5 +7,12 @@
 from repoze.folder import Folder
 from pyramid.security import Allow, Everyone
-from ow.utilities import GPXMinidomParser
+
+from ow.utilities import (
+    GPXMinidomParser,
+    copy_blob,
+    create_blob,
+)
+
+from ow.fit import Fit
 
 
@@ -56,4 +63,8 @@
         self.tracking_file = kw.get('tracking_file', None)  # Blob
         self.tracking_filetype = ''  # unicode string
+        # attr to store ANT fit files. For now this file is used to
+        # generate a gpx-encoded tracking file we then use through
+        # the whole app
+        self.fit_file = kw.get('fit_file', None)  # Blob
 
     @property
@@ -188,4 +199,38 @@
         return None
 
+    @property
+    def tracking_file_path(self):
+        """
+        Get the path to the blob file attached as a tracking file.
+
+        First check if the file was not committed to the db yet (new workout
+        not saved yet) and use the path to the temporary file on the fs.
+        If none is found there, go for the real blob file in the blobs
+        directory
+        """
+        path = None
+        if self.tracking_file:
+            path = self.tracking_file._p_blob_uncommitted
+            if path is None:
+                path = self.tracking_file._p_blob_committed
+        return path
+
+    @property
+    def fit_file_path(self):
+        """
+        Get the path to the blob file attached as a fit file.
+
+        First check if the file was not committed to the db yet (new workout
+        not saved yet) and use the path to the temporary file on the fs.
+        If none is found there, go for the real blob file in the blobs
+        directory
+        """
+        path = None
+        if self.fit_file:
+            path = self.fit_file._p_blob_uncommitted
+            if path is None:
+                path = self.fit_file._p_blob_committed
+        return path
+
     def load_from_file(self):
         """
@@ -195,4 +240,6 @@
         if self.tracking_filetype == 'gpx':
             self.load_from_gpx()
+        elif self.tracking_filetype == 'fit':
+            self.load_from_fit()
 
     def load_from_gpx(self):
@@ -283,4 +330,65 @@
         return parser.tracks
 
+    def load_from_fit(self):
+        """
+        Try to load data from an ANT-compatible .fit file (if any has been
+        added to this workout).
+
+        "Load data" means:
+
+        1. Copy over the uploaded fit file to self.fit_file, so we can keep
+           that copy around for future use
+
+        2. generate a gpx object from the fit file
+
+        3. save the gpx object as the tracking_file, which then will be used
+           by the current code to display and gather data to be displayed/shown
+           to the user.
+
+        4. Grab some basic info from the fit file and store it in the Workout
+        """
+        # backup the fit file
+        self.fit_file = copy_blob(self.tracking_file)
+
+        # create an instance of our Fit class
+        fit = Fit(self.fit_file_path)
+        fit.load()
+
+        # fit -> gpx and store that as the main tracking file
+        self.tracking_file = create_blob(fit.gpx, 'gpx')
+        self.tracking_filetype = 'gpx'
+
+        # grab the needed data from the fit file, update the workout
+        self.start = fit.data['start']
+        # ensure this datetime start object is timezone-aware
+        self.start = self.start.replace(tzinfo=timezone.utc)
+        # duration comes in seconds, store a timedelta
+        self.duration = timedelta(seconds=fit.data['duration'])
+        # distance comes in meters
+        self.distance = Decimal(fit.data['distance']) / Decimal(1000.00)
+        self.uphill = Decimal(fit.data['uphill'])
+        self.downhill = Decimal(fit.data['downhill'])
+        # If the user did not provide us with a title, build one from the
+        # info in the fit file
+        if not self.title:
+            self.title = fit.name
+
+        if fit.data['avg_hr']:
+            self.hr_avg = Decimal(fit.data['avg_hr'])
+            self.hr_min = Decimal(fit.data['min_hr'])
+            self.hr_max = Decimal(fit.data['max_hr'])
+
+        if fit.data['avg_cad']:
+            self.cad_avg = Decimal(fit.data['avg_cad'])
+            self.cad_min = Decimal(fit.data['min_cad'])
+            self.cad_max = Decimal(fit.data['max_cad'])
+
+        if fit.data['avg_atemp']:
+            self.atemp_avg = Decimal(fit.data['avg_atemp'])
+            self.atemp_min = Decimal(fit.data['min_atemp'])
+            self.atemp_max = Decimal(fit.data['max_atemp'])
+
+        return True
+
     @property
     def has_tracking_file(self):
@@ -290,2 +398,6 @@
     def has_gpx(self):
         return self.has_tracking_file and self.tracking_filetype == 'gpx'
+
+    @property
+    def has_fit(self):
+        return self.fit_file is not None
Index: ow/tests/models/test_workout.py
===================================================================
--- ow/tests/models/test_workout.py	(revision e3d7b13eff7b6857b89e8250f1d28469acaa11fc)
+++ ow/tests/models/test_workout.py	(revision 53bb3e54424d0f8bd311b9f034cc74fcf64d1889)
@@ -3,5 +3,5 @@
 from datetime import datetime, timedelta, timezone
 from decimal import Decimal
-from unittest.mock import patch
+from unittest.mock import patch, Mock
 
 import pytest
@@ -195,4 +195,17 @@
         assert workout.atemp['max'] == 12
         assert workout.atemp['avg'] == 5
+
+    def test_tracking_file_path(self):
+        workout = Workout()
+        # no tracking file, path is None
+        assert workout.tracking_file_path is None
+        # workout still not saved to the db
+        workout.tracking_file = Mock()
+        workout.tracking_file._p_blob_uncommitted = '/tmp/blobtempfile'
+        workout.tracking_file._p_blob_committed = None
+        assert workout.tracking_file_path == '/tmp/blobtempfile'
+        workout.tracking_file._p_blob_uncommitted = None
+        workout.tracking_file._p_blob_committed = '/var/db/blobs/blobfile'
+        assert workout.tracking_file_path == '/var/db/blobs/blobfile'
 
     def test_load_from_file_invalid(self):
Index: ow/utilities.py
===================================================================
--- ow/utilities.py	(revision e3d7b13eff7b6857b89e8250f1d28469acaa11fc)
+++ ow/utilities.py	(revision 53bb3e54424d0f8bd311b9f034cc74fcf64d1889)
@@ -1,7 +1,10 @@
 import re
-import datetime
+from datetime import datetime
+from decimal import Decimal
+from shutil import copyfileobj
+
 from unidecode import unidecode
 from xml.dom import minidom
-from decimal import Decimal
+from ZODB.blob import Blob
 
 
@@ -79,8 +82,8 @@
                 rfc3339 = trkpt.getElementsByTagName('time')[0].firstChild.data
                 try:
-                    t = datetime.datetime.strptime(
+                    t = datetime.strptime(
                         rfc3339, '%Y-%m-%dT%H:%M:%S.%fZ')
                 except ValueError:
-                    t = datetime.datetime.strptime(
+                    t = datetime.strptime(
                         rfc3339, '%Y-%m-%dT%H:%M:%SZ')
 
@@ -113,2 +116,70 @@
                     'cad': cad,
                     'atemp': atemp})
+
+
+def semicircles_to_degrees(semicircles):
+    return semicircles * (180 / pow(2, 31))
+
+
+def degrees_to_semicircles(degrees):
+    return degrees * (pow(2, 31) / 180)
+
+
+def miles_to_kms(miles):
+    factor = 0.62137119
+    return miles / factor
+
+
+def kms_to_miles(kms):
+    factor = 0.62137119
+    return kms * factor
+
+
+def meters_to_kms(meters):
+    return meters / 1000
+
+
+def kms_to_meters(kms):
+    return kms * 1000
+
+
+def mps_to_kmph(mps):
+    """
+    Transform a value from meters-per-second to kilometers-per-hour
+    """
+    return mps * 3.6
+
+
+def kmph_to_mps(kmph):
+    """
+    Transform a value from kilometers-per-hour to meters-per-second
+    """
+    return kmph * 0.277778
+
+
+def copy_blob(blob):
+    """
+    Create a copy of a blob object, returning another blob object that is
+    the copy of the given blob file.
+    """
+    new_blob = Blob()
+    if getattr(blob, 'file_extension', None):
+        new_blob.file_extension = blob.file_extension
+    with blob.open('r') as orig_blob, new_blob.open('w') as dest_blob:
+        orig_blob.seek(0)
+        copyfileobj(orig_blob, dest_blob)
+    return new_blob
+
+
+def create_blob(data, file_extension):
+    """
+    Create a ZODB blob file from some data, return the blob object
+    """
+    blob = Blob()
+    blob.file_extension = file_extension
+    with blob.open('w') as open_blob:
+        # use .encode() to convert the string to bytes if needed
+        if not isinstance(data, bytes):
+            data = data.encode('utf-8')
+        open_blob.write(data)
+    return blob
