source: OpenWorkouts-current/ow/tests/models/test_workout.py @ 78af3d1

currentfeature/docs
Last change on this file since 78af3d1 was 78af3d1, checked in by Borja Lopez <borja@…>, 5 years ago

Fix permissions. From now on users can see (and edit, delete, etc) their own data

  • Property mode set to 100644
File size: 24.3 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, Deny, ALL_PERMISSIONS
9
10from ow.models.workout import Workout
11from ow.models.user import User
12from ow.models.root import OpenWorkouts
13from ow.utilities import create_blob
14
15from ow.tests.helpers import join
16
17
18class TestWorkoutModels(object):
19
20    @pytest.fixture
21    def root(self):
22        root = OpenWorkouts()
23        root['john'] = User(firstname='John', lastname='Doe',
24                            email='john.doe@example.net')
25        root['john'].password = 's3cr3t'
26        root['john']['1'] = Workout(
27            start=datetime(2015, 6, 28, 12, 55, tzinfo=timezone.utc),
28            duration=timedelta(minutes=60),
29            distance=30
30        )
31        return root
32
33    def test__acl__(self, root):
34        # First check permissions for a workout without parent
35        workout = Workout()
36        with pytest.raises(AttributeError):
37            workout.__acl__()
38        # Now permissions on a workout that has been added to a user
39        uid = str(root['john'].uid)
40        workout = root['john']['1']
41        permissions = [
42            (Allow, uid, 'view'),
43            (Allow, uid, 'edit'),
44            (Allow, uid, 'delete'),
45            (Deny, Everyone, ALL_PERMISSIONS)
46        ]
47        assert workout.__acl__() == permissions
48
49    def test_runthrough(self, root):
50        """
51        Just a simple run through to see if those objects click
52        """
53        root['joe'] = User(firstname='Joe', lastname='Di Maggio')
54        assert(u'Joe Di Maggio' == root['joe'].fullname)
55        joe = root['joe']
56        start = datetime(2015, 6, 5, 19, 1, tzinfo=timezone.utc)
57        duration = timedelta(minutes=20)
58        distance = Decimal('0.25')  # 250m
59        w = Workout(start=start, duration=duration, sport='swimming',
60                    notes=u'Yay, I swam!', distance=distance)
61        joe['1'] = w
62        expected = datetime(2015, 6, 5, 19, 21, tzinfo=timezone.utc)
63        assert expected == joe['1'].end
64        assert 250 == joe['1'].distance * 1000
65
66    def test_workout_id(self, root):
67        assert root['john']['1'].workout_id == '1'
68
69    def test_owner(self, root):
70        # workout with owner
71        assert root['john']['1'].owner == root['john']
72        # workout without owner
73        w = Workout()
74        assert w.owner is None
75
76    def test_end(self, root):
77        # workout without duration, it has no end
78        workout = Workout()
79        assert workout.end is None
80        # workout with duration, end is start_time + duration
81        expected = datetime(2015, 6, 28, 13, 55, tzinfo=timezone.utc)
82        assert root['john']['1'].end == expected
83
84    def test_start_date(self):
85        start_date = datetime.now()
86        workout = Workout(start=start_date)
87        assert workout.start_date == start_date.strftime('%d/%m/%Y')
88
89    def test_start_time(self):
90        start_date = datetime.now()
91        workout = Workout(start=start_date)
92        assert workout.start_time == start_date.strftime('%H:%M')
93
94    def test_start_in_timezone(self):
95        start_date = datetime.now(tz=timezone.utc)
96        str_start_date = start_date.strftime('%d/%m/%Y %H:%M (%Z)')
97        workout = Workout(start=start_date)
98        assert workout.start_in_timezone('UTC') == str_start_date
99        assert workout.start_in_timezone('Europe/Madrid') != str_start_date
100        assert workout.start_in_timezone('America/Vancouver') != str_start_date
101
102    def test_end_in_timezone(self):
103        start_date = datetime.now(tz=timezone.utc)
104        end_date = start_date + timedelta(minutes=60)
105        str_end_date = end_date.strftime('%d/%m/%Y %H:%M (%Z)')
106        workout = Workout(start=start_date, duration=timedelta(minutes=60))
107        assert workout.end_in_timezone('UTC') == str_end_date
108        assert workout.end_in_timezone('Europe/Madrid') != str_end_date
109        assert workout.end_in_timezone('America/Vancouver') != str_end_date
110
111    def test_split_duration(self):
112        # return the same hours, minutes, seconds we provided when creating
113        # the workout instance
114        duration = timedelta(hours=1, minutes=30, seconds=15)
115        workout = Workout(duration=duration)
116        assert workout.split_duration() == (1, 30, 15)
117        # If the duration is longer than a day, return the calculation of
118        # in hours, minutes, seconds too
119        duration = timedelta(days=1, hours=1, minutes=30, seconds=15)
120        workout = Workout(duration=duration)
121        assert workout.split_duration() == (25, 30, 15)
122
123    def test_duration_hours_minutes_seconds(self):
124        duration = timedelta(hours=1, minutes=30, seconds=15)
125        workout = Workout(duration=duration)
126        assert workout.duration_hours == '01'
127        assert workout.duration_minutes == '30'
128        assert workout.duration_seconds == '15'
129
130    def test__duration(self):
131        # covering the property that shows the duration of a workout properly
132        # formatted in hours:minutes:seconds
133        duration = timedelta(hours=1, minutes=30, seconds=15)
134        workout = Workout(duration=duration)
135        assert workout._duration == '01:30:15'
136
137    def test_rounded_distance_no_value(self):
138        workout = Workout()
139        assert workout.rounded_distance == '-'
140
141    def test_rounded_distance(self):
142        workout = Workout()
143        workout.distance = 44.44444444
144        assert workout.rounded_distance == 44.44
145
146    def test_has_hr(self):
147        workout = Workout()
148        assert not workout.has_hr
149        workout.hr_min = 90
150        assert not workout.has_hr
151        workout.hr_max = 180
152        assert not workout.has_hr
153        workout.hr_avg = 120
154        assert workout.has_hr
155
156    def test_hr(self):
157        workout = Workout()
158        assert workout.hr is None
159        workout.hr_min = 90
160        assert workout.hr is None
161        workout.hr_max = 180
162        assert workout.hr is None
163        workout.hr_avg = 120
164        assert workout.hr['min'] == 90
165        assert workout.hr['max'] == 180
166        assert workout.hr['avg'] == 120
167
168    def test_has_cad(self):
169        workout = Workout()
170        assert not workout.has_cad
171        workout.cad_min = 0
172        assert not workout.has_cad
173        workout.cad_max = 110
174        assert not workout.has_cad
175        workout.cad_avg = 50
176        assert workout.has_cad
177
178    def test_cad(self):
179        workout = Workout()
180        assert workout.cad is None
181        workout.cad_min = 0
182        assert workout.cad is None
183        workout.cad_max = 110
184        assert workout.cad is None
185        workout.cad_avg = 50
186        assert workout.cad['min'] == 0
187        assert workout.cad['max'] == 110
188        assert workout.cad['avg'] == 50
189
190    def test_has_atemp(self):
191        workout = Workout()
192        assert not workout.has_atemp
193        workout.atemp_min = 0
194        assert not workout.has_atemp
195        workout.atemp_max = 12
196        assert not workout.has_atemp
197        workout.atemp_avg = 5
198        assert workout.has_atemp
199
200    def test_atemp(self):
201        workout = Workout()
202        assert workout.atemp is None
203        workout.atemp_min = 0
204        assert workout.atemp is None
205        workout.atemp_max = 12
206        assert workout.atemp is None
207        workout.atemp_avg = 5
208        assert workout.atemp['min'] == 0
209        assert workout.atemp['max'] == 12
210        assert workout.atemp['avg'] == 5
211
212    def test_tracking_file_path(self):
213        workout = Workout()
214        # no tracking file, path is None
215        assert workout.tracking_file_path is None
216        # workout still not saved to the db
217        workout.tracking_file = Mock()
218        workout.tracking_file._uncommitted.return_value = '/tmp/blobtempfile'
219        workout.tracking_file.committed.return_value = None
220        assert workout.tracking_file_path == '/tmp/blobtempfile'
221        workout.tracking_file._uncommitted.return_value = None
222        workout.tracking_file.committed.return_value = '/var/db/blobs/blobfile'
223        assert workout.tracking_file_path == '/var/db/blobs/blobfile'
224
225    def test_fit_file_path(self):
226        workout = Workout()
227        # no tracking file, path is None
228        assert workout.fit_file_path is None
229        # workout still not saved to the db
230        workout.fit_file = Mock()
231        workout.fit_file._uncommitted.return_value = '/tmp/blobtempfile'
232        workout.fit_file.committed.return_value = None
233        assert workout.fit_file_path == '/tmp/blobtempfile'
234        workout.fit_file._uncommitted.return_value = None
235        workout.fit_file.committed.return_value = '/var/db/blobs/blobfile'
236        assert workout.fit_file_path == '/var/db/blobs/blobfile'
237
238    def test_load_from_file_invalid(self):
239        workout = Workout()
240        workout.tracking_filetype = 'alf'
241        with patch.object(workout, 'load_from_gpx') as lfg:
242            workout.load_from_file()
243            assert not lfg.called
244
245    def test_load_from_file_gpx(self):
246        workout = Workout()
247        workout.tracking_filetype = 'gpx'
248        with patch.object(workout, 'load_from_gpx') as lfg:
249            workout.load_from_file()
250            assert lfg.called
251
252    def test_load_from_file_fit(self):
253        workout = Workout()
254        workout.tracking_filetype = 'fit'
255        with patch.object(workout, 'load_from_fit') as lff:
256            workout.load_from_file()
257            assert lff.called
258
259    gpx_params = (
260        # GPX 1.0 file, no extensions
261        ('fixtures/20131013.gpx', {
262            'start': datetime(2013, 10, 13, 5, 28, 26, tzinfo=timezone.utc),
263            'duration': timedelta(seconds=27652),
264            'distance': Decimal(98.12598431852807),
265            'title': 'A ride I will never forget',
266            'blob': 'path',
267            'hr': {'min': None, 'max': None, 'avg': None},
268            'cad': {'min': None, 'max': None, 'avg': None},
269            'atemp': {'min': None, 'max': None, 'avg': None}}),
270        # GPX 1.0 file, no extensions, missing elevation
271        ('fixtures/20131013-without-elevation.gpx', {
272            'start': datetime(2013, 10, 13, 5, 28, 26, tzinfo=timezone.utc),
273            'duration': timedelta(seconds=27652),
274            'distance': Decimal(98.12598431852807),
275            'title': 'A ride I will never forget',
276            'blob': None,
277            'hr': {'min': None, 'max': None, 'avg': None},
278            'cad': {'min': None, 'max': None, 'avg': None},
279            'atemp': {'min': None, 'max': None, 'avg': None}}),
280        # GPX 1.1 file with extensions
281        ('fixtures/20160129-with-extensions.gpx', {
282            'start': datetime(2016, 1, 29, 8, 12, 9, tzinfo=timezone.utc),
283            'duration': timedelta(seconds=7028),
284            'distance': Decimal(48.37448557752049237024039030),
285            'title': 'Cota counterclockwise + end bonus',
286            'blob': 'path',
287            'hr': {'min': Decimal(100), 'max': Decimal(175),
288                   'avg': Decimal(148.365864144454008055618032813072)},
289            'cad': {'min': Decimal(0), 'max': Decimal(110),
290                    'avg': Decimal(67.41745485812553740326741187)},
291            'atemp': {'min': Decimal(-4), 'max': Decimal(14),
292                      'avg': Decimal(-0.3869303525365434221840068788)}}),
293        )
294
295    @pytest.mark.parametrize(('filename', 'expected'), gpx_params)
296    def test_load_from_gpx(self, filename, expected):
297        """
298        Load a gpx file located in tests/fixtures using the load_from_gpx()
299        method of the Workout model, then check that certain attrs on the
300        workout are updated correctly
301        """
302        # expected values
303        start = expected['start']
304        duration = expected['duration']
305        distance = expected['distance']
306        title = expected['title']
307        blob = expected['blob']
308        hr = expected['hr']
309        cad = expected['cad']
310        atemp = expected['atemp']
311
312        workout = Workout()
313
314        # Check the values are different by default
315        assert workout.start != start
316        assert workout.duration != duration
317        assert workout.distance != distance
318
319        gpx_file_path = os.path.join(
320            os.path.dirname(os.path.dirname(__file__)), filename)
321        with patch.object(workout, 'tracking_file') as tf:
322            with open(gpx_file_path, 'r') as gpx_file:
323                tf.open.return_value = BytesIO(gpx_file.read().encode('utf-8'))
324                # Set the path to the blob object containing the gpx file.
325                # more info in models.workout.Workout.parse_gpx()
326                tf._p_blob_uncommitted = gpx_file_path
327                if blob is None:
328                    # set the uncommited blob to None, mimicing what happens
329                    # with a workout saved into the db (transaction.commit())
330                    tf._p_blob_uncommitted = None
331                    tf._p_blob_committed = gpx_file_path
332                # Without this, has_gpx() will return False
333                workout.tracking_filetype = 'gpx'
334                res = workout.load_from_gpx()
335                assert res is True
336                assert workout.start == start
337                assert workout.duration == duration
338                assert isinstance(workout.distance, Decimal)
339                assert round(workout.distance) == round(distance)
340                # The title of the workout is taken from the gpx file
341                assert workout.title == title
342                for k in hr.keys():
343                    # We use 'fail' as the fallback in the getattr call because
344                    # None is one of the posible values, and we want to be sure
345                    # those attrs are there
346                    #
347                    # The keys are the same for the hr, cad and atemp dicts, so
348                    # we can do all tests in one loop
349                    #
350                    # If the expected value is not None, use round() to avoid
351                    # problems when comparing long Decimal objects
352                    value = getattr(workout, 'hr_'+k, 'fail')
353                    if hr[k] is None:
354                        assert hr[k] == value
355                    else:
356                        assert round(hr[k]) == round(value)
357
358                    value = getattr(workout, 'cad_'+k, 'fail')
359                    if cad[k] is None:
360                        assert cad[k] == value
361                    else:
362                        assert round(cad[k]) == round(value)
363
364                    value = getattr(workout, 'atemp_'+k, 'fail')
365                    if atemp[k] is None:
366                        assert atemp[k] == value
367                    else:
368                        assert round(atemp[k]) == round(value)
369
370    def test_load_from_gpx_no_tracks(self):
371        """
372        If we load an empty (but valid) gpx file (i.e., no tracks information)
373        the attrs on the workout are not updated, the call to load_from_gpx()
374        returns False
375        """
376        workout = Workout()
377
378        # We do not check the start time, it would need some mocking on the
379        # datetime module and there is no need really
380        assert workout.duration is None
381        assert workout.distance is None
382
383        gpx_file_path = os.path.join(
384            os.path.dirname(os.path.dirname(__file__)),
385            'fixtures/empty.gpx')
386        with patch.object(workout, 'tracking_file') as tf:
387            with open(gpx_file_path, 'r') as gpx_file:
388                tf.open.return_value = BytesIO(gpx_file.read().encode('utf-8'))
389                res = workout.load_from_gpx()
390                assert res is False
391                assert workout.duration is None
392                assert workout.distance is None
393                assert workout.title == ''
394                for k in ['max', 'min', 'avg']:
395                    for a in ['hr_', 'cad_', 'atemp_']:
396                        assert getattr(workout, a+k, 'fail') is None
397
398    def test_parse_gpx_no_gpx_file(self):
399        """
400        Test the behaviour of parse_gpx() when we call it on a workout without
401        a gpx tracking file. The behaviour of such method when the workout has
402        a gpx tracking file is covered by the test_load_from_gpx() test above
403        """
404        workout = Workout()
405        res = workout.parse_gpx()
406        assert res == {}
407
408    fit_params = (
409        # complete fit file from a garmin 520 device
410        ('fixtures/20181230_101115.fit', {
411            'start': datetime(2018, 12, 30, 9, 11, 15, tzinfo=timezone.utc),
412            'duration': timedelta(0, 13872, 45000),
413            'distance': Decimal('103.4981999999999970896169543'),
414            'title': 'Synapse cycling',
415            'hr': {'min': 93, 'max': 170, 'avg': 144},
416            'cad': {'min': 0, 'max': 121, 'avg': 87},
417            'atemp': {'min': -4, 'max': 15, 'avg': 2},
418            'gpx_file': 'fixtures/20181230_101115.gpx'}),
419        # fit file from a garmin 520 without heart rate or cadence data
420        ('fixtures/20181231_110728.fit', {
421            'start': datetime(2018, 12, 31, 10, 7, 28, tzinfo=timezone.utc),
422            'duration': timedelta(0, 2373, 142000),
423            'distance': Decimal('6.094909999999999854480847716'),
424            'title': 'Synapse cycling',
425            'hr': None,
426            'cad': None,
427            'atemp': {'min': -1, 'max': 11, 'avg': 1},
428            'gpx_file': 'fixtures/20181231_110728.gpx'}),
429    )
430
431    @pytest.mark.parametrize(('filename', 'expected'), fit_params)
432    def test_load_from_fit(self, filename, expected):
433        """
434        Load a fit file located in tests/fixtures using the load_from_fit()
435        method of the Workout model, then check that certain attrs on the
436        workout are updated correctly.
437
438        Ensure also that the proper gpx file is created automatically from
439        the fit file, with the proper contents (we have a matching gpx file
440        in tests/fixtures for each fit file)
441        """
442        # expected values
443        start = expected['start']
444        duration = expected['duration']
445        distance = expected['distance']
446        title = expected['title']
447        hr = expected['hr']
448        cad = expected['cad']
449        atemp = expected['atemp']
450        # gpx_file = expected['gpx_file']
451
452        workout = Workout()
453
454        # Check the values are different by default
455        assert workout.start != start
456        assert workout.duration != duration
457        assert workout.distance != distance
458
459        # by default no tracking file and no fit file are associated with this
460        # workout.
461        assert workout.tracking_file is None
462        assert workout.fit_file is None
463        assert not workout.has_tracking_file
464        assert not workout.has_gpx
465        assert not workout.has_fit
466
467        fit_file_path = os.path.join(
468            os.path.dirname(os.path.dirname(__file__)), filename)
469
470        # gpx_file_path = os.path.join(
471        #     os.path.dirname(os.path.dirname(__file__)), gpx_file)
472
473        # add the fit file as a blob to tracking_file
474        with open(fit_file_path, 'rb') as fit_file:
475            fit_blob = create_blob(fit_file.read(), file_extension='fit',
476                                   binary=True)
477        workout.tracking_file = fit_blob
478        workout.tracking_filetype = 'fit'
479
480        res = workout.load_from_fit()
481
482        assert res is True
483        assert workout.start == start
484        assert workout.duration == duration
485        assert isinstance(workout.distance, Decimal)
486        assert round(workout.distance) == round(distance)
487        # The title of the workout is taken from the gpx file
488        assert workout.title == title
489
490        if hr is not None:
491            for k in hr.keys():
492                # We use 'fail' as the fallback in the getattr call
493                # because None is one of the posible values, and we
494                # want to be sure those attrs are there
495                value = getattr(workout, 'hr_' + k, 'fail')
496                # Use round() to avoid problems when comparing long
497                # Decimal objects
498                assert round(hr[k]) == round(value)
499
500        if cad is not None:
501            for k in cad.keys():
502                value = getattr(workout, 'cad_' + k, 'fail')
503                assert round(cad[k]) == round(value)
504
505        if atemp is not None:
506            for k in atemp.keys():
507                value = getattr(workout, 'atemp_' + k, 'fail')
508                assert round(atemp[k]) == round(value)
509
510        assert workout.tracking_file is not None
511        # the tracking file type is set back to gpx, as we have
512        # automatically generated the gpx version
513        assert workout.tracking_filetype == 'gpx'
514        assert workout.fit_file is not None
515        assert workout.has_tracking_file
516        assert workout.has_gpx
517        assert workout.has_fit
518
519    def test_has_tracking_file(self, root):
520        workout = root['john']['1']
521        # without tracking file
522        assert not workout.has_tracking_file
523        # with tracking file
524        workout.tracking_file = 'faked tracking file'
525        assert workout.has_tracking_file
526
527    def test_has_gpx(self, root):
528        workout = root['john']['1']
529        # without tracking file
530        assert not workout.has_gpx
531        workout.tracking_filetype = 'fit'
532        assert not workout.has_gpx
533        # with non-gpx tracking file
534        workout.tracking_file = 'faked tracking file'
535        workout.tracking_filetype = 'fit'
536        assert not workout.has_gpx
537        # with gpx tracking file
538        workout.tracking_file = 'faked tracking file'
539        workout.tracking_filetype = 'gpx'
540        assert workout.has_gpx
541
542    def test_has_fit(self, root):
543        workout = root['john']['1']
544        # without tracking file
545        assert not workout.has_fit
546        # tracking_file is a fit, this should not happen, as uploading a fit
547        # puts the fit file into .fit_file and generates a gpx for
548        # .tracking_file
549        workout.tracking_file = 'faked tracking file'
550        workout.tracking_filetype = 'fit'
551        assert not workout.has_fit
552        # now, having a fit file returns true
553        workout.fit_file = 'faked fit file'
554        assert workout.has_fit
555        # no matter what we have in tracking_file
556        workout.tracking_filetype = 'gpx'
557        assert workout.has_fit
558        workout.tracking_file = None
559        workout.tracking_filetype = None
560        assert workout.has_fit
561
562    @patch('ow.models.workout.os')
563    @patch('ow.models.workout.save_map_screenshot')
564    def test_map_screenshot_no_gpx(self, sms, os, root):
565        workout = root['john']['1']
566        assert workout.map_screenshot is None
567        assert not os.path.abspath.called
568        assert not os.path.dirname.called
569        assert not os.path.join.called
570        assert not os.path.exists.called
571        assert not sms.called
572
573    @patch('ow.models.workout.os')
574    @patch('ow.models.workout.save_map_screenshot')
575    def test_map_screenshot_save(self, sms, os, root):
576        """
577        A workout with a tracking file has no map screenshot, one is
578        saved to the filesystem.
579        This test simply asserts the calls to the separate methods that
580        look for existing screenshots and save a new one
581        """
582        os.path.abspath.return_value = 'current_dir'
583        os.path.join.side_effect = join
584        # This forces the "save screenshot" code to be run
585        os.path.exists.return_value = False
586
587        workout = root['john']['1']
588        workout.tracking_file = 'faked gpx file'
589        workout.tracking_filetype = 'gpx'
590
591        uid = str(root['john'].uid)
592        assert workout.map_screenshot == 'ow:/static/maps/' + uid + '/1.png'
593        assert os.path.abspath.called
594        assert os.path.dirname.called
595        assert os.path.join.call_count == 2
596        assert os.path.exists.called
597        sms.assert_called_once_with(workout)
598
599    @patch('ow.models.workout.os')
600    @patch('ow.models.workout.save_map_screenshot')
601    def test_map_screenshot_do_not_save(self, sms, os, root):
602        """
603        A workout with a tracking file has a map screenshot, the path to that
604        is returned without doing anything else
605        """
606        os.path.abspath.return_value = 'current_dir'
607        os.path.join.side_effect = join
608        # This forces the "save screenshot" code NOT to be run
609        os.path.exists.return_value = True
610
611        workout = root['john']['1']
612        workout.tracking_file = 'faked gpx file'
613        workout.tracking_filetype = 'gpx'
614
615        uid = str(root['john'].uid)
616        assert workout.map_screenshot == 'ow:/static/maps/' + uid + '/1.png'
617        assert os.path.abspath.called
618        assert os.path.dirname.called
619        assert os.path.join.call_count == 2
620        assert os.path.exists.called
621        assert not sms.called
Note: See TracBrowser for help on using the repository browser.