source: OpenWorkouts-current/ow/models/workout.py @ f89b2f1

current
Last change on this file since f89b2f1 was f89b2f1, checked in by Borja Lopez <borja@…>, 5 years ago

(#83) Fixed a bug when loading gpx files that lead to very long values of elevation
to be displayed everywhere.

  • Property mode set to 100644
File size: 15.6 KB
Line 
1import os
2import textwrap
3from datetime import datetime, timedelta, timezone
4from decimal import Decimal
5
6import pytz
7import gpxpy
8from repoze.folder import Folder
9from pyramid.security import Allow, Deny, Everyone, ALL_PERMISSIONS
10
11from ow.utilities import (
12    GPXMinidomParser,
13    copy_blob,
14    create_blob,
15    mps_to_kmph,
16    timedelta_to_hms
17)
18
19from ow.fit import Fit
20
21
22class Workout(Folder):
23
24    __parent__ = __name__ = None
25
26    def __acl__(self):
27        """
28        If the workout is owned by a given user, only that user have access to
29        it (for now). If not, everybody can view it, only admins can edit it.
30        """
31        uid = self.__parent__.uid
32        permissions = [
33            (Allow, str(uid), 'view'),
34            (Allow, str(uid), 'edit'),
35            (Allow, str(uid), 'delete'),
36            (Deny, Everyone, ALL_PERMISSIONS)
37        ]
38        return permissions
39
40    def __init__(self, **kw):
41        super(Workout, self).__init__()
42        # we do store datetime objects with UTC timezone
43        self.start = kw.get('start', datetime.now(timezone.utc))
44        self.sport = kw.get('sport', 'unknown')  # string
45        self.title = kw.get('title', '')  # unicode string
46        self.notes = kw.get('notes', '')  # unicode string
47        self.duration = kw.get('duration', None)  # a timedelta object
48        self.distance = kw.get('distance', None)  # kilometers, Decimal
49        self.speed = kw.get('speed', {})
50        self.hr_min = kw.get('hr_min', None)  # bpm, Decimal
51        self.hr_max = kw.get('hr_max', None)  # bpm, Decimal
52        self.hr_avg = kw.get('hr_avg', None)  # bpm, Decimal
53        self.uphill = kw.get('uphill', None)
54        self.downhill = kw.get('downhill', None)
55        self.cad_min = kw.get('cad_min', None)
56        self.cad_max = kw.get('cad_max', None)
57        self.cad_avg = kw.get('cad_avg', None)
58        self.atemp_min = kw.get('atemp_min', None)
59        self.atemp_max = kw.get('atemp_max', None)
60        self.atemp_avg = kw.get('atemp_avg', None)
61        self.tracking_file = kw.get('tracking_file', None)  # Blob
62        self.tracking_filetype = ''  # unicode string
63        # attr to store ANT fit files. For now this file is used to
64        # generate a gpx-encoded tracking file we then use through
65        # the whole app
66        self.fit_file = kw.get('fit_file', None)  # Blob
67
68    @property
69    def workout_id(self):
70        return self.__name__
71
72    @property
73    def owner(self):
74        return self.__parent__
75
76    @property
77    def end(self):
78        if not self.duration:
79            return None
80        return self.start + self.duration
81
82    @property
83    def start_date(self):
84        return self.start.strftime('%d/%m/%Y')
85
86    @property
87    def start_time(self):
88        return self.start.strftime('%H:%M')
89
90    def start_in_timezone(self, timezone):
91        """
92        Return a string representation of the start date and time,
93        localized into the given timezone
94        """
95        _start = self.start.astimezone(pytz.timezone(timezone))
96        return _start.strftime('%d/%m/%Y %H:%M (%Z)')
97
98    def end_in_timezone(self, timezone):
99        """
100        Return a string representation of the end date and time,
101        localized into the given timezone
102        """
103        _end = self.end.astimezone(pytz.timezone(timezone))
104        return _end.strftime('%d/%m/%Y %H:%M (%Z)')
105
106    def split_duration(self):
107        return timedelta_to_hms(self.duration)
108
109    @property
110    def duration_hours(self):
111        return str(self.split_duration()[0]).zfill(2)
112
113    @property
114    def duration_minutes(self):
115        return str(self.split_duration()[1]).zfill(2)
116
117    @property
118    def duration_seconds(self):
119        return str(self.split_duration()[2]).zfill(2)
120
121    @property
122    def _duration(self):
123        return ':'.join(
124            [self.duration_hours, self.duration_minutes, self.duration_seconds]
125        )
126
127    @property
128    def rounded_distance(self):
129        """
130        Return rounded value for distance, '-' if the workout has no distance
131        data (weight lifting, martial arts, table tennis, etc)
132        """
133        if self.distance:
134            return round(self.distance, 2)
135        return '-'
136
137    @property
138    def hashed(self):
139        """
140        Return a unique hash that we use to look for duplicated workouts.
141        The hash contains: owner's uid, start time, duration and distance
142        """
143        hashed = ''
144        if self.owner is not None:
145            hashed += str(self.owner.uid)
146        hashed += self.start.strftime('%Y%m%d%H%M%S')
147        hashed += str(self.duration.seconds)
148        hashed += str(self.distance)
149        return hashed
150
151    @property
152    def trimmed_notes(self):
153        """
154        Return a string with a reduced version of the full notes for this
155        workout.
156        """
157        return textwrap.shorten(self.notes, width=225, placeholder=' ...')
158
159    @property
160    def has_hr(self):
161        """
162        True if this workout has heart rate data, False otherwise
163        """
164        data = [self.hr_min, self.hr_max, self.hr_avg]
165        return data.count(None) == 0
166
167    @property
168    def hr(self):
169        """
170        Return a dict with rounded values for hr min, max and average,
171        return None if there is no heart rate data for this workout
172        """
173        if self.has_hr:
174            return {'min': round(self.hr_min),
175                    'max': round(self.hr_max),
176                    'avg': round(self.hr_avg)}
177        return None
178
179    @property
180    def has_cad(self):
181        """
182        True if this workout has cadence data, False otherwise
183        """
184        data = [self.cad_min, self.cad_max, self.cad_avg]
185        return data.count(None) == 0
186
187    @property
188    def cad(self):
189        """
190        Return a dict with rounded values for cadence min, max and average
191        return None if there is no cadence data for this workout
192        """
193        if self.has_cad:
194            return {'min': round(self.cad_min),
195                    'max': round(self.cad_max),
196                    'avg': round(self.cad_avg)}
197        return None
198
199    @property
200    def has_atemp(self):
201        """
202        True if this workout has temperature data, False otherwise
203        """
204        data = [self.atemp_min, self.atemp_max, self.atemp_avg]
205        return data.count(None) == 0
206
207    @property
208    def atemp(self):
209        """
210        Return a dict with rounded values for temperature min, max and average
211        return None if there is no temperature data for this workout
212        """
213        if self.has_atemp:
214            return {'min': round(self.atemp_min),
215                    'max': round(self.atemp_max),
216                    'avg': round(self.atemp_avg)}
217        return None
218
219    @property
220    def tracking_file_path(self):
221        """
222        Get the path to the blob file attached as a tracking file.
223
224        First check if the file was not committed to the db yet (new workout
225        not saved yet) and use the path to the temporary file on the fs.
226        If none is found there, go for the real blob file in the blobs
227        directory
228        """
229        path = None
230        if self.tracking_file:
231            path = self.tracking_file._uncommitted()
232            if path is None:
233                path = self.tracking_file.committed()
234        return path
235
236    @property
237    def fit_file_path(self):
238        """
239        Get the path to the blob file attached as a fit file.
240
241        First check if the file was not committed to the db yet (new workout
242        not saved yet) and use the path to the temporary file on the fs.
243        If none is found there, go for the real blob file in the blobs
244        directory
245        """
246        path = None
247        if self.fit_file:
248            path = self.fit_file._uncommitted()
249            if path is None:
250                path = self.fit_file.committed()
251        return path
252
253    def load_from_file(self):
254        """
255        Check which kind of tracking file we have for this workout, then call
256        the proper method to load info from the tracking file
257        """
258        if self.tracking_filetype == 'gpx':
259            self.load_from_gpx()
260        elif self.tracking_filetype == 'fit':
261            self.load_from_fit()
262
263    def load_from_gpx(self):
264        """
265        Load some information from an attached gpx file. Return True if data
266        had been succesfully loaded, False otherwise
267        """
268        with self.tracking_file.open() as gpx_file:
269            gpx_contents = gpx_file.read()
270            gpx_contents = gpx_contents.decode('utf-8')
271            gpx = gpxpy.parse(gpx_contents)
272            if gpx.tracks:
273                track = gpx.tracks[0]
274                # Start time comes in UTC/GMT/ZULU
275                time_bounds = track.get_time_bounds()
276                self.start = time_bounds.start_time
277                # ensure this datetime start object is timezone-aware
278                self.start = self.start.replace(tzinfo=timezone.utc)
279                # get_duration returns seconds
280                self.duration = timedelta(seconds=track.get_duration())
281                # length_3d returns meters
282                self.distance = round(
283                    Decimal(track.length_3d()) / Decimal(1000.00), 2)
284                ud = track.get_uphill_downhill()
285                self.uphill = round(Decimal(ud.uphill), 0)
286                self.downhill = round(Decimal(ud.downhill), 0)
287                # If the user did not provide us with a title, and the gpx has
288                # one, use that
289                if not self.title and track.name:
290                    self.title = track.name
291
292                # Hack to calculate some values from the GPX 1.1 extensions,
293                # using our own parser (gpxpy does not support those yet)
294                tracks = self.parse_gpx()
295                hr = []
296                cad = []
297                atemp = []
298                for t in tracks:
299                    hr += [
300                        d['hr'] for d in tracks[t] if d['hr'] is not None]
301                    cad += [
302                        d['cad'] for d in tracks[t] if d['cad'] is not None]
303                    atemp += [
304                        d['atemp'] for d in tracks[t]
305                        if d['atemp'] is not None]
306
307                if hr:
308                    self.hr_min = Decimal(min(hr))
309                    self.hr_avg = Decimal(sum(hr)) / Decimal(len(hr))
310                    self.hr_max = Decimal(max(hr))
311
312                if cad:
313                    self.cad_min = Decimal(min(cad))
314                    self.cad_avg = Decimal(sum(cad)) / Decimal(len(cad))
315                    self.cad_max = Decimal(max(cad))
316
317                if atemp:
318                    self.atemp_min = Decimal(min(atemp))
319                    self.atemp_avg = Decimal(sum(atemp)) / Decimal(len(atemp))
320                    self.atemp_max = Decimal(max(atemp))
321
322                return True
323
324        return False
325
326    def parse_gpx(self):
327        """
328        Parse the gpx using minidom.
329
330        This method is needed as a workaround to get HR/CAD/TEMP values from
331        gpx 1.1 extensions (gpxpy does not handle them correctly so far)
332        """
333        if not self.has_gpx:
334            # No gpx, nothing to return
335            return {}
336
337        # Get the path to the blob file, first check if the file was not
338        # committed to the db yet (new workout not saved yet) and use the
339        # path to the temporary file on the fs. If none is found there, go
340        # for the final blob file
341        gpx_path = self.tracking_file._p_blob_uncommitted
342        if gpx_path is None:
343            gpx_path = self.tracking_file._p_blob_committed
344
345        # Create a parser, load the gpx and parse the tracks
346        parser = GPXMinidomParser(gpx_path)
347        parser.load_gpx()
348        parser.parse_tracks()
349        return parser.tracks
350
351    def load_from_fit(self):
352        """
353        Try to load data from an ANT-compatible .fit file (if any has been
354        added to this workout).
355
356        "Load data" means:
357
358        1. Copy over the uploaded fit file to self.fit_file, so we can keep
359           that copy around for future use
360
361        2. generate a gpx object from the fit file
362
363        3. save the gpx object as the tracking_file, which then will be used
364           by the current code to display and gather data to be displayed/shown
365           to the user.
366
367        4. Grab some basic info from the fit file and store it in the Workout
368        """
369
370        # we can call load_from_fit afterwards for updates. In such case, check
371        # if the tracking file is a fit file uploaded to override the previous
372        # one. If not, just reuse the existing fit file
373        if self.tracking_filetype == 'fit':
374            # backup the fit file
375            self.fit_file = copy_blob(self.tracking_file)
376
377        # create an instance of our Fit class
378        fit = Fit(self.fit_file_path)
379        fit.load()
380
381        # fit -> gpx and store that as the main tracking file
382        self.tracking_file = create_blob(fit.gpx, 'gpx')
383        self.tracking_filetype = 'gpx'
384
385        # grab the needed data from the fit file, update the workout
386        self.sport = fit.data['sport']
387        self.start = fit.data['start']
388        # ensure this datetime start object is timezone-aware
389        self.start = self.start.replace(tzinfo=timezone.utc)
390        # duration comes in seconds, store a timedelta
391        self.duration = timedelta(seconds=fit.data['duration'])
392        if fit.data['distance']:
393            # distance comes in meters
394            self.distance = Decimal(fit.data['distance']) / Decimal(1000.00)
395        if fit.data['uphill']:
396            self.uphill = Decimal(fit.data['uphill'])
397        if fit.data['downhill']:
398            self.downhill = Decimal(fit.data['downhill'])
399        # If the user did not provide us with a title, build one from the
400        # info in the fit file
401        if not self.title:
402            self.title = fit.name
403
404        if fit.data['max_speed']:
405            self.speed['max'] = mps_to_kmph(fit.data['max_speed'])
406
407        if fit.data['avg_speed']:
408            self.speed['avg'] = mps_to_kmph(fit.data['avg_speed'])
409
410        if fit.data['avg_hr']:
411            self.hr_avg = Decimal(fit.data['avg_hr'])
412            self.hr_min = Decimal(fit.data['min_hr'])
413            self.hr_max = Decimal(fit.data['max_hr'])
414
415        if fit.data['avg_cad']:
416            self.cad_avg = Decimal(fit.data['avg_cad'])
417            self.cad_min = Decimal(fit.data['min_cad'])
418            self.cad_max = Decimal(fit.data['max_cad'])
419
420        if fit.data['avg_atemp']:
421            self.atemp_avg = Decimal(fit.data['avg_atemp'])
422            self.atemp_min = Decimal(fit.data['min_atemp'])
423            self.atemp_max = Decimal(fit.data['max_atemp'])
424
425        return True
426
427    @property
428    def has_tracking_file(self):
429        return self.tracking_file is not None
430
431    @property
432    def has_gpx(self):
433        return self.has_tracking_file and self.tracking_filetype == 'gpx'
434
435    @property
436    def has_fit(self):
437        return self.fit_file is not None
438
439    @property
440    def map_screenshot_name(self):
441        return os.path.join(
442            str(self.owner.uid), str(self.workout_id) + '.png')
443
444    @property
445    def map_screenshot_path(self):
446        current_path = os.path.abspath(os.path.dirname(__file__))
447        return os.path.join(
448            current_path, '../static/maps', self.map_screenshot_name)
449
450    @property
451    def map_screenshot(self):
452        """
453        Return the static path to the screenshot image of the map for
454        this workout (works only for workouts with gps tracking)
455        """
456        if not self.has_gpx:
457            return None
458
459        if not os.path.exists(self.map_screenshot_path):
460            return None
461
462        static_path = os.path.join('static/maps', self.map_screenshot_name)
463        # return a string we can use with request.static_url
464        return 'ow:' + static_path
Note: See TracBrowser for help on using the repository browser.