source: OpenWorkouts-current/ow/tests/models/test_workout.py @ 31adfa5

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

(#14) Timezones support:

  • Added pytz as a new dependency, please install it in your existing envs:

pip install pytz

  • Added a timezone attribute to users, to store in which timezone they are, defaults to 'UTC'. Ensure any users you could have in your database have such attribute. You can add it in pshell:

for user in root.users:

user.timezone = 'UTC'

request.tm.commit()

  • Modified schemas/templates/views to let users choose their timezone based on a list of "common" timezones provided by pytz
  • Added two methods to the Workout model so we can get the start and end dates formatted in the appropiate timezone (all datetime objects are stored in UTC)
  • Modified the templates where we show workout dates and times so the new timezone-formatting methods are used.
  • Property mode set to 100644
File size: 14.7 KB
Line 
1import os
2from io import BytesIO
3from datetime import datetime, timedelta, timezone
4from decimal import Decimal
5from unittest.mock import patch
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_end(self, root):
63        # workout without duration, it has no end
64        workout = Workout()
65        assert workout.end is None
66        # workout with duration, end is start_time + duration
67        expected = datetime(2015, 6, 28, 13, 55, tzinfo=timezone.utc)
68        assert root['john']['1'].end == expected
69
70    def test_start_date(self):
71        start_date = datetime.now()
72        workout = Workout(start=start_date)
73        assert workout.start_date == start_date.strftime('%d/%m/%Y')
74
75    def test_start_time(self):
76        start_date = datetime.now()
77        workout = Workout(start=start_date)
78        assert workout.start_time == start_date.strftime('%H:%M')
79
80    def test_start_in_timezone(self):
81        start_date = datetime.now(tz=timezone.utc)
82        str_start_date = start_date.strftime('%d/%m/%Y %H:%M (%Z)')
83        workout = Workout(start=start_date)
84        assert workout.start_in_timezone('UTC') == str_start_date
85        assert workout.start_in_timezone('Europe/Madrid') != str_start_date
86        assert workout.start_in_timezone('America/Vancouver') != str_start_date
87
88    def test_end_in_timezone(self):
89        start_date = datetime.now(tz=timezone.utc)
90        end_date = start_date + timedelta(minutes=60)
91        str_end_date = end_date.strftime('%d/%m/%Y %H:%M (%Z)')
92        workout = Workout(start=start_date, duration=timedelta(minutes=60))
93        assert workout.end_in_timezone('UTC') == str_end_date
94        assert workout.end_in_timezone('Europe/Madrid') != str_end_date
95        assert workout.end_in_timezone('America/Vancouver') != str_end_date
96
97    def test_split_duration(self):
98        # return the same hours, minutes, seconds we provided when creating
99        # the workout instance
100        duration = timedelta(hours=1, minutes=30, seconds=15)
101        workout = Workout(duration=duration)
102        assert workout.split_duration() == (1, 30, 15)
103        # If the duration is longer than a day, return the calculation of
104        # in hours, minutes, seconds too
105        duration = timedelta(days=1, hours=1, minutes=30, seconds=15)
106        workout = Workout(duration=duration)
107        assert workout.split_duration() == (25, 30, 15)
108
109    def test_duration_hours_minutes_seconds(self):
110        duration = timedelta(hours=1, minutes=30, seconds=15)
111        workout = Workout(duration=duration)
112        assert workout.duration_hours == '01'
113        assert workout.duration_minutes == '30'
114        assert workout.duration_seconds == '15'
115
116    def test_rounded_distance_no_value(self):
117        workout = Workout()
118        assert workout.rounded_distance == '-'
119
120    def test_rounded_distance(self):
121        workout = Workout()
122        workout.distance = 44.44444444
123        assert workout.rounded_distance == 44.4
124
125    def test_has_hr(self):
126        workout = Workout()
127        assert not workout.has_hr
128        workout.hr_min = 90
129        assert not workout.has_hr
130        workout.hr_max = 180
131        assert not workout.has_hr
132        workout.hr_avg = 120
133        assert workout.has_hr
134
135    def test_hr(self):
136        workout = Workout()
137        assert workout.hr is None
138        workout.hr_min = 90
139        assert workout.hr is None
140        workout.hr_max = 180
141        assert workout.hr is None
142        workout.hr_avg = 120
143        assert workout.hr['min'] == 90
144        assert workout.hr['max'] == 180
145        assert workout.hr['avg'] == 120
146
147    def test_has_cad(self):
148        workout = Workout()
149        assert not workout.has_cad
150        workout.cad_min = 0
151        assert not workout.has_cad
152        workout.cad_max = 110
153        assert not workout.has_cad
154        workout.cad_avg = 50
155        assert workout.has_cad
156
157    def test_cad(self):
158        workout = Workout()
159        assert workout.cad is None
160        workout.cad_min = 0
161        assert workout.cad is None
162        workout.cad_max = 110
163        assert workout.cad is None
164        workout.cad_avg = 50
165        assert workout.cad['min'] == 0
166        assert workout.cad['max'] == 110
167        assert workout.cad['avg'] == 50
168
169    def test_has_atemp(self):
170        workout = Workout()
171        assert not workout.has_atemp
172        workout.atemp_min = 0
173        assert not workout.has_atemp
174        workout.atemp_max = 12
175        assert not workout.has_atemp
176        workout.atemp_avg = 5
177        assert workout.has_atemp
178
179    def test_atemp(self):
180        workout = Workout()
181        assert workout.atemp is None
182        workout.atemp_min = 0
183        assert workout.atemp is None
184        workout.atemp_max = 12
185        assert workout.atemp is None
186        workout.atemp_avg = 5
187        assert workout.atemp['min'] == 0
188        assert workout.atemp['max'] == 12
189        assert workout.atemp['avg'] == 5
190
191    def test_load_from_file_invalid(self):
192        workout = Workout()
193        workout.tracking_filetype = 'alf'
194        with patch.object(workout, 'load_from_gpx') as lfg:
195            workout.load_from_file()
196            assert not lfg.called
197
198    def test_load_from_file_gpx(self):
199        workout = Workout()
200        workout.tracking_filetype = 'gpx'
201        with patch.object(workout, 'load_from_gpx') as lfg:
202            workout.load_from_file()
203            assert lfg.called
204
205    gpx_params = (
206        # GPX 1.0 file, no extensions
207        ('fixtures/20131013.gpx', {
208            'start': datetime(2013, 10, 13, 5, 28, 26, tzinfo=timezone.utc),
209            'duration': timedelta(seconds=27652),
210            'distance': Decimal(98.12598431852807),
211            'title': 'A ride I will never forget',
212            'blob': 'path',
213            'hr': {'min': None, 'max': None, 'avg': None},
214            'cad': {'min': None, 'max': None, 'avg': None},
215            'atemp': {'min': None, 'max': None, 'avg': None}}),
216        # GPX 1.0 file, no extensions, missing elevation
217        ('fixtures/20131013-without-elevation.gpx', {
218            'start': datetime(2013, 10, 13, 5, 28, 26, tzinfo=timezone.utc),
219            'duration': timedelta(seconds=27652),
220            'distance': Decimal(98.12598431852807),
221            'title': 'A ride I will never forget',
222            'blob': None,
223            'hr': {'min': None, 'max': None, 'avg': None},
224            'cad': {'min': None, 'max': None, 'avg': None},
225            'atemp': {'min': None, 'max': None, 'avg': None}}),
226        # GPX 1.1 file with extensions
227        ('fixtures/20160129-with-extensions.gpx', {
228            'start': datetime(2016, 1, 29, 8, 12, 9, tzinfo=timezone.utc),
229            'duration': timedelta(seconds=7028),
230            'distance': Decimal(48.37448557752049237024039030),
231            'title': 'Cota counterclockwise + end bonus',
232            'blob': 'path',
233            'hr': {'min': Decimal(100), 'max': Decimal(175),
234                   'avg': Decimal(148.365864144454008055618032813072)},
235            'cad': {'min': Decimal(0), 'max': Decimal(110),
236                    'avg': Decimal(67.41745485812553740326741187)},
237            'atemp': {'min': Decimal(-4), 'max': Decimal(14),
238                      'avg': Decimal(-0.3869303525365434221840068788)}}),
239        )
240
241    @pytest.mark.parametrize(('filename', 'expected'), gpx_params)
242    def test_load_from_gpx(self, filename, expected):
243        """
244        Load a gpx file located in tests/fixtures using the load_from_gpx()
245        method of the Workout model, then check that certain attrs on the
246        workout are updated correctly
247        """
248        # expected values
249        start = expected['start']
250        duration = expected['duration']
251        distance = expected['distance']
252        title = expected['title']
253        blob = expected['blob']
254        hr = expected['hr']
255        cad = expected['cad']
256        atemp = expected['atemp']
257
258        workout = Workout()
259
260        # Check the values are different by default
261        assert workout.start != start
262        assert workout.duration != duration
263        assert workout.distance != distance
264
265        gpx_file_path = os.path.join(
266            os.path.dirname(os.path.dirname(__file__)), filename)
267        with patch.object(workout, 'tracking_file') as tf:
268            with open(gpx_file_path, 'r') as gpx_file:
269                tf.open.return_value = BytesIO(gpx_file.read().encode('utf-8'))
270                # Set the path to the blob object containing the gpx file.
271                # more info in models.workout.Workout.parse_gpx()
272                tf._p_blob_uncommitted = gpx_file_path
273                if blob is None:
274                    # set the uncommited blob to None, mimicing what happens
275                    # with a workout saved into the db (transaction.commit())
276                    tf._p_blob_uncommitted = None
277                    tf._p_blob_committed = gpx_file_path
278                # Without this, has_gpx() will return False
279                workout.tracking_filetype = 'gpx'
280                res = workout.load_from_gpx()
281                assert res is True
282                assert workout.start == start
283                assert workout.duration == duration
284                assert isinstance(workout.distance, Decimal)
285                assert round(workout.distance) == round(distance)
286                # The title of the workout is taken from the gpx file
287                assert workout.title == title
288                for k in hr.keys():
289                    # We use 'fail' as the fallback in the getattr call because
290                    # None is one of the posible values, and we want to be sure
291                    # those attrs are there
292                    #
293                    # The keys are the same for the hr, cad and atemp dicts, so
294                    # we can do all tests in one loop
295                    #
296                    # If the expected value is not None, use round() to avoid
297                    # problems when comparing long Decimal objects
298                    value = getattr(workout, 'hr_'+k, 'fail')
299                    if hr[k] is None:
300                        assert hr[k] == value
301                    else:
302                        assert round(hr[k]) == round(value)
303
304                    value = getattr(workout, 'cad_'+k, 'fail')
305                    if cad[k] is None:
306                        assert cad[k] == value
307                    else:
308                        assert round(cad[k]) == round(value)
309
310                    value = getattr(workout, 'atemp_'+k, 'fail')
311                    if atemp[k] is None:
312                        assert atemp[k] == value
313                    else:
314                        assert round(atemp[k]) == round(value)
315
316    def test_load_from_gpx_no_tracks(self):
317        """
318        If we load an empty (but valid) gpx file (i.e., no tracks information)
319        the attrs on the workout are not updated, the call to load_from_gpx()
320        returns False
321        """
322        workout = Workout()
323
324        # We do not check the start time, it would need some mocking on the
325        # datetime module and there is no need really
326        assert workout.duration is None
327        assert workout.distance is None
328
329        gpx_file_path = os.path.join(
330            os.path.dirname(os.path.dirname(__file__)),
331            'fixtures/empty.gpx')
332        with patch.object(workout, 'tracking_file') as tf:
333            with open(gpx_file_path, 'r') as gpx_file:
334                tf.open.return_value = BytesIO(gpx_file.read().encode('utf-8'))
335                res = workout.load_from_gpx()
336                assert res is False
337                assert workout.duration is None
338                assert workout.distance is None
339                assert workout.title == ''
340                for k in ['max', 'min', 'avg']:
341                    for a in ['hr_', 'cad_', 'atemp_']:
342                        assert getattr(workout, a+k, 'fail') is None
343
344    def test_parse_gpx_no_gpx_file(self):
345        """
346        Test the behaviour of parse_gpx() when we call it on a workout without
347        a gpx tracking file. The behaviour of such method when the workout has
348        a gpx tracking file is covered by the test_load_from_gpx() test above
349        """
350        workout = Workout()
351        res = workout.parse_gpx()
352        assert res == {}
353
354    def test_has_tracking_file(self, root):
355        workout = root['john']['1']
356        # without tracking file
357        assert workout.has_tracking_file is False
358        # with tracking file
359        workout.tracking_file = 'faked tracking file'
360        assert workout.has_tracking_file is True
361
362    def test_has_gpx(self, root):
363        workout = root['john']['1']
364        # without tracking file
365        assert workout.has_gpx is False
366        workout.tracking_filetype = 'fit'
367        assert workout.has_gpx is False
368        # with non-gpx tracking file
369        workout.tracking_file = 'faked tracking file'
370        workout.tracking_filetype = 'fit'
371        assert workout.has_gpx is False
372        # with gpx tracking file
373        workout.tracking_file = 'faked tracking file'
374        workout.tracking_filetype = 'gpx'
375        assert workout.has_gpx is True
Note: See TracBrowser for help on using the repository browser.