source: OpenWorkouts-current/ow/tests/models/test_workout.py @ 53bb3e5

currentfeature/docs
Last change on this file since 53bb3e5 was 53bb3e5, checked in by borja <borja@…>, 5 years ago

(#13) - fit files parsing + (#26) - generate .gpx from .fit

  • Added fitparse as a new dependency IMPORTANT: please install it in your existing envs:

pip install python-fitparse

  • Added new attribute to workouts to store attached fit files as Blob objects. IMPORTANT: please update any workouts you have in your db, adding the fit_file attribute to them (None by default)
  • Added new module with the code needed to interact with .fit files (parse, gather data, transform to gpx)
  • Added code to "load" a workout from a fit file
  • Added tools and helpers to transform values (meters->kilometers, meters-per-second->kms-per-hour, semicircles-to-degrees, etc)
  • Refactored some imports
  • Property mode set to 100644
File size: 15.5 KB
Line 
1import os
2from io import BytesIO
3from datetime import datetime, timedelta, timezone
4from decimal import Decimal
5from unittest.mock import patch, Mock
6
7import pytest
8from pyramid.security import Allow, Everyone
9
10from ow.models.workout import Workout
11from ow.models.user import User
12from ow.models.root import OpenWorkouts
13
14
15class TestWorkoutModels(object):
16
17    @pytest.fixture
18    def root(self):
19        root = OpenWorkouts()
20        root['john'] = User(firstname='John', lastname='Doe',
21                            email='john.doe@example.net')
22        root['john'].password = 's3cr3t'
23        root['john']['1'] = Workout(
24            start=datetime(2015, 6, 28, 12, 55, tzinfo=timezone.utc),
25            duration=timedelta(minutes=60),
26            distance=30
27        )
28        return root
29
30    def test__acl__(self, root):
31        # First check permissions for a workout without parent
32        permissions = [(Allow, Everyone, 'view'),
33                       (Allow, 'group:admins', 'edit')]
34        workout = Workout()
35        assert workout.__acl__() == permissions
36
37        # Now permissions on a workout that has been added to a user
38        uid = str(root['john'].uid)
39        permissions = [(Allow, uid, 'view'), (Allow, uid, 'edit')]
40        assert root['john']['1'].__acl__() == permissions
41
42    def test_runthrough(self, root):
43        """
44        Just a simple run through to see if those objects click
45        """
46        root['joe'] = User(firstname='Joe', lastname='Di Maggio')
47        assert(u'Joe Di Maggio' == root['joe'].fullname)
48        joe = root['joe']
49        start = datetime(2015, 6, 5, 19, 1, tzinfo=timezone.utc)
50        duration = timedelta(minutes=20)
51        distance = Decimal('0.25')  # 250m
52        w = Workout(start=start, duration=duration, sport='swimming',
53                    notes=u'Yay, I swam!', distance=distance)
54        joe['1'] = w
55        expected = datetime(2015, 6, 5, 19, 21, tzinfo=timezone.utc)
56        assert expected == joe['1'].end
57        assert 250 == joe['1'].distance * 1000
58
59    def test_workout_id(self, root):
60        assert root['john']['1'].workout_id == '1'
61
62    def test_owner(self, root):
63        # workout with owner
64        assert root['john']['1'].owner == root['john']
65        # workout without owner
66        w = Workout()
67        assert w.owner is None
68
69    def test_end(self, root):
70        # workout without duration, it has no end
71        workout = Workout()
72        assert workout.end is None
73        # workout with duration, end is start_time + duration
74        expected = datetime(2015, 6, 28, 13, 55, tzinfo=timezone.utc)
75        assert root['john']['1'].end == expected
76
77    def test_start_date(self):
78        start_date = datetime.now()
79        workout = Workout(start=start_date)
80        assert workout.start_date == start_date.strftime('%d/%m/%Y')
81
82    def test_start_time(self):
83        start_date = datetime.now()
84        workout = Workout(start=start_date)
85        assert workout.start_time == start_date.strftime('%H:%M')
86
87    def test_start_in_timezone(self):
88        start_date = datetime.now(tz=timezone.utc)
89        str_start_date = start_date.strftime('%d/%m/%Y %H:%M (%Z)')
90        workout = Workout(start=start_date)
91        assert workout.start_in_timezone('UTC') == str_start_date
92        assert workout.start_in_timezone('Europe/Madrid') != str_start_date
93        assert workout.start_in_timezone('America/Vancouver') != str_start_date
94
95    def test_end_in_timezone(self):
96        start_date = datetime.now(tz=timezone.utc)
97        end_date = start_date + timedelta(minutes=60)
98        str_end_date = end_date.strftime('%d/%m/%Y %H:%M (%Z)')
99        workout = Workout(start=start_date, duration=timedelta(minutes=60))
100        assert workout.end_in_timezone('UTC') == str_end_date
101        assert workout.end_in_timezone('Europe/Madrid') != str_end_date
102        assert workout.end_in_timezone('America/Vancouver') != str_end_date
103
104    def test_split_duration(self):
105        # return the same hours, minutes, seconds we provided when creating
106        # the workout instance
107        duration = timedelta(hours=1, minutes=30, seconds=15)
108        workout = Workout(duration=duration)
109        assert workout.split_duration() == (1, 30, 15)
110        # If the duration is longer than a day, return the calculation of
111        # in hours, minutes, seconds too
112        duration = timedelta(days=1, hours=1, minutes=30, seconds=15)
113        workout = Workout(duration=duration)
114        assert workout.split_duration() == (25, 30, 15)
115
116    def test_duration_hours_minutes_seconds(self):
117        duration = timedelta(hours=1, minutes=30, seconds=15)
118        workout = Workout(duration=duration)
119        assert workout.duration_hours == '01'
120        assert workout.duration_minutes == '30'
121        assert workout.duration_seconds == '15'
122
123    def test_rounded_distance_no_value(self):
124        workout = Workout()
125        assert workout.rounded_distance == '-'
126
127    def test_rounded_distance(self):
128        workout = Workout()
129        workout.distance = 44.44444444
130        assert workout.rounded_distance == 44.4
131
132    def test_has_hr(self):
133        workout = Workout()
134        assert not workout.has_hr
135        workout.hr_min = 90
136        assert not workout.has_hr
137        workout.hr_max = 180
138        assert not workout.has_hr
139        workout.hr_avg = 120
140        assert workout.has_hr
141
142    def test_hr(self):
143        workout = Workout()
144        assert workout.hr is None
145        workout.hr_min = 90
146        assert workout.hr is None
147        workout.hr_max = 180
148        assert workout.hr is None
149        workout.hr_avg = 120
150        assert workout.hr['min'] == 90
151        assert workout.hr['max'] == 180
152        assert workout.hr['avg'] == 120
153
154    def test_has_cad(self):
155        workout = Workout()
156        assert not workout.has_cad
157        workout.cad_min = 0
158        assert not workout.has_cad
159        workout.cad_max = 110
160        assert not workout.has_cad
161        workout.cad_avg = 50
162        assert workout.has_cad
163
164    def test_cad(self):
165        workout = Workout()
166        assert workout.cad is None
167        workout.cad_min = 0
168        assert workout.cad is None
169        workout.cad_max = 110
170        assert workout.cad is None
171        workout.cad_avg = 50
172        assert workout.cad['min'] == 0
173        assert workout.cad['max'] == 110
174        assert workout.cad['avg'] == 50
175
176    def test_has_atemp(self):
177        workout = Workout()
178        assert not workout.has_atemp
179        workout.atemp_min = 0
180        assert not workout.has_atemp
181        workout.atemp_max = 12
182        assert not workout.has_atemp
183        workout.atemp_avg = 5
184        assert workout.has_atemp
185
186    def test_atemp(self):
187        workout = Workout()
188        assert workout.atemp is None
189        workout.atemp_min = 0
190        assert workout.atemp is None
191        workout.atemp_max = 12
192        assert workout.atemp is None
193        workout.atemp_avg = 5
194        assert workout.atemp['min'] == 0
195        assert workout.atemp['max'] == 12
196        assert workout.atemp['avg'] == 5
197
198    def test_tracking_file_path(self):
199        workout = Workout()
200        # no tracking file, path is None
201        assert workout.tracking_file_path is None
202        # workout still not saved to the db
203        workout.tracking_file = Mock()
204        workout.tracking_file._p_blob_uncommitted = '/tmp/blobtempfile'
205        workout.tracking_file._p_blob_committed = None
206        assert workout.tracking_file_path == '/tmp/blobtempfile'
207        workout.tracking_file._p_blob_uncommitted = None
208        workout.tracking_file._p_blob_committed = '/var/db/blobs/blobfile'
209        assert workout.tracking_file_path == '/var/db/blobs/blobfile'
210
211    def test_load_from_file_invalid(self):
212        workout = Workout()
213        workout.tracking_filetype = 'alf'
214        with patch.object(workout, 'load_from_gpx') as lfg:
215            workout.load_from_file()
216            assert not lfg.called
217
218    def test_load_from_file_gpx(self):
219        workout = Workout()
220        workout.tracking_filetype = 'gpx'
221        with patch.object(workout, 'load_from_gpx') as lfg:
222            workout.load_from_file()
223            assert lfg.called
224
225    gpx_params = (
226        # GPX 1.0 file, no extensions
227        ('fixtures/20131013.gpx', {
228            'start': datetime(2013, 10, 13, 5, 28, 26, tzinfo=timezone.utc),
229            'duration': timedelta(seconds=27652),
230            'distance': Decimal(98.12598431852807),
231            'title': 'A ride I will never forget',
232            'blob': 'path',
233            'hr': {'min': None, 'max': None, 'avg': None},
234            'cad': {'min': None, 'max': None, 'avg': None},
235            'atemp': {'min': None, 'max': None, 'avg': None}}),
236        # GPX 1.0 file, no extensions, missing elevation
237        ('fixtures/20131013-without-elevation.gpx', {
238            'start': datetime(2013, 10, 13, 5, 28, 26, tzinfo=timezone.utc),
239            'duration': timedelta(seconds=27652),
240            'distance': Decimal(98.12598431852807),
241            'title': 'A ride I will never forget',
242            'blob': None,
243            'hr': {'min': None, 'max': None, 'avg': None},
244            'cad': {'min': None, 'max': None, 'avg': None},
245            'atemp': {'min': None, 'max': None, 'avg': None}}),
246        # GPX 1.1 file with extensions
247        ('fixtures/20160129-with-extensions.gpx', {
248            'start': datetime(2016, 1, 29, 8, 12, 9, tzinfo=timezone.utc),
249            'duration': timedelta(seconds=7028),
250            'distance': Decimal(48.37448557752049237024039030),
251            'title': 'Cota counterclockwise + end bonus',
252            'blob': 'path',
253            'hr': {'min': Decimal(100), 'max': Decimal(175),
254                   'avg': Decimal(148.365864144454008055618032813072)},
255            'cad': {'min': Decimal(0), 'max': Decimal(110),
256                    'avg': Decimal(67.41745485812553740326741187)},
257            'atemp': {'min': Decimal(-4), 'max': Decimal(14),
258                      'avg': Decimal(-0.3869303525365434221840068788)}}),
259        )
260
261    @pytest.mark.parametrize(('filename', 'expected'), gpx_params)
262    def test_load_from_gpx(self, filename, expected):
263        """
264        Load a gpx file located in tests/fixtures using the load_from_gpx()
265        method of the Workout model, then check that certain attrs on the
266        workout are updated correctly
267        """
268        # expected values
269        start = expected['start']
270        duration = expected['duration']
271        distance = expected['distance']
272        title = expected['title']
273        blob = expected['blob']
274        hr = expected['hr']
275        cad = expected['cad']
276        atemp = expected['atemp']
277
278        workout = Workout()
279
280        # Check the values are different by default
281        assert workout.start != start
282        assert workout.duration != duration
283        assert workout.distance != distance
284
285        gpx_file_path = os.path.join(
286            os.path.dirname(os.path.dirname(__file__)), filename)
287        with patch.object(workout, 'tracking_file') as tf:
288            with open(gpx_file_path, 'r') as gpx_file:
289                tf.open.return_value = BytesIO(gpx_file.read().encode('utf-8'))
290                # Set the path to the blob object containing the gpx file.
291                # more info in models.workout.Workout.parse_gpx()
292                tf._p_blob_uncommitted = gpx_file_path
293                if blob is None:
294                    # set the uncommited blob to None, mimicing what happens
295                    # with a workout saved into the db (transaction.commit())
296                    tf._p_blob_uncommitted = None
297                    tf._p_blob_committed = gpx_file_path
298                # Without this, has_gpx() will return False
299                workout.tracking_filetype = 'gpx'
300                res = workout.load_from_gpx()
301                assert res is True
302                assert workout.start == start
303                assert workout.duration == duration
304                assert isinstance(workout.distance, Decimal)
305                assert round(workout.distance) == round(distance)
306                # The title of the workout is taken from the gpx file
307                assert workout.title == title
308                for k in hr.keys():
309                    # We use 'fail' as the fallback in the getattr call because
310                    # None is one of the posible values, and we want to be sure
311                    # those attrs are there
312                    #
313                    # The keys are the same for the hr, cad and atemp dicts, so
314                    # we can do all tests in one loop
315                    #
316                    # If the expected value is not None, use round() to avoid
317                    # problems when comparing long Decimal objects
318                    value = getattr(workout, 'hr_'+k, 'fail')
319                    if hr[k] is None:
320                        assert hr[k] == value
321                    else:
322                        assert round(hr[k]) == round(value)
323
324                    value = getattr(workout, 'cad_'+k, 'fail')
325                    if cad[k] is None:
326                        assert cad[k] == value
327                    else:
328                        assert round(cad[k]) == round(value)
329
330                    value = getattr(workout, 'atemp_'+k, 'fail')
331                    if atemp[k] is None:
332                        assert atemp[k] == value
333                    else:
334                        assert round(atemp[k]) == round(value)
335
336    def test_load_from_gpx_no_tracks(self):
337        """
338        If we load an empty (but valid) gpx file (i.e., no tracks information)
339        the attrs on the workout are not updated, the call to load_from_gpx()
340        returns False
341        """
342        workout = Workout()
343
344        # We do not check the start time, it would need some mocking on the
345        # datetime module and there is no need really
346        assert workout.duration is None
347        assert workout.distance is None
348
349        gpx_file_path = os.path.join(
350            os.path.dirname(os.path.dirname(__file__)),
351            'fixtures/empty.gpx')
352        with patch.object(workout, 'tracking_file') as tf:
353            with open(gpx_file_path, 'r') as gpx_file:
354                tf.open.return_value = BytesIO(gpx_file.read().encode('utf-8'))
355                res = workout.load_from_gpx()
356                assert res is False
357                assert workout.duration is None
358                assert workout.distance is None
359                assert workout.title == ''
360                for k in ['max', 'min', 'avg']:
361                    for a in ['hr_', 'cad_', 'atemp_']:
362                        assert getattr(workout, a+k, 'fail') is None
363
364    def test_parse_gpx_no_gpx_file(self):
365        """
366        Test the behaviour of parse_gpx() when we call it on a workout without
367        a gpx tracking file. The behaviour of such method when the workout has
368        a gpx tracking file is covered by the test_load_from_gpx() test above
369        """
370        workout = Workout()
371        res = workout.parse_gpx()
372        assert res == {}
373
374    def test_has_tracking_file(self, root):
375        workout = root['john']['1']
376        # without tracking file
377        assert workout.has_tracking_file is False
378        # with tracking file
379        workout.tracking_file = 'faked tracking file'
380        assert workout.has_tracking_file is True
381
382    def test_has_gpx(self, root):
383        workout = root['john']['1']
384        # without tracking file
385        assert workout.has_gpx is False
386        workout.tracking_filetype = 'fit'
387        assert workout.has_gpx is False
388        # with non-gpx tracking file
389        workout.tracking_file = 'faked tracking file'
390        workout.tracking_filetype = 'fit'
391        assert workout.has_gpx is False
392        # with gpx tracking file
393        workout.tracking_file = 'faked tracking file'
394        workout.tracking_filetype = 'gpx'
395        assert workout.has_gpx is True
Note: See TracBrowser for help on using the repository browser.