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

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

Done some improvements on the dashboard:

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