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

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

(#52) - Make map screenshot generation async and non-blocker of the dashboard
and user profile pages:

  • workout.map_screenshot is a property again, returns a string with the static path (suitable for use with request.static_url()) for the map screenshot file if exists, none otherwise
  • added a couple of helpers to build the proper screenshot name and full path on the filesystem
  • added a view to generate the map if needed, returning the full static url to the screenshot file in a json-encoded stream
  • added a new js ow tool that looks for workouts that still don't have a map screenshot, calling the view to generate that screenshot and updating the map screenshot when the file is ready
  • modified the dashboard and user profile templates, so a dummy/placeholder image is shown when no map screenshot is yet ready, plus setup and calls for the js tool that generates the maps.
  • Property mode set to 100644
File size: 15.1 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 trimmed_notes(self):
139        """
140        Return a string with a reduced version of the full notes for this
141        workout.
142        """
143        return textwrap.shorten(self.notes, width=225, placeholder=' ...')
144
145    @property
146    def has_hr(self):
147        """
148        True if this workout has heart rate data, False otherwise
149        """
150        data = [self.hr_min, self.hr_max, self.hr_avg]
151        return data.count(None) == 0
152
153    @property
154    def hr(self):
155        """
156        Return a dict with rounded values for hr min, max and average,
157        return None if there is no heart rate data for this workout
158        """
159        if self.has_hr:
160            return {'min': round(self.hr_min),
161                    'max': round(self.hr_max),
162                    'avg': round(self.hr_avg)}
163        return None
164
165    @property
166    def has_cad(self):
167        """
168        True if this workout has cadence data, False otherwise
169        """
170        data = [self.cad_min, self.cad_max, self.cad_avg]
171        return data.count(None) == 0
172
173    @property
174    def cad(self):
175        """
176        Return a dict with rounded values for cadence min, max and average
177        return None if there is no cadence data for this workout
178        """
179        if self.has_cad:
180            return {'min': round(self.cad_min),
181                    'max': round(self.cad_max),
182                    'avg': round(self.cad_avg)}
183        return None
184
185    @property
186    def has_atemp(self):
187        """
188        True if this workout has temperature data, False otherwise
189        """
190        data = [self.atemp_min, self.atemp_max, self.atemp_avg]
191        return data.count(None) == 0
192
193    @property
194    def atemp(self):
195        """
196        Return a dict with rounded values for temperature min, max and average
197        return None if there is no temperature data for this workout
198        """
199        if self.has_atemp:
200            return {'min': round(self.atemp_min),
201                    'max': round(self.atemp_max),
202                    'avg': round(self.atemp_avg)}
203        return None
204
205    @property
206    def tracking_file_path(self):
207        """
208        Get the path to the blob file attached as a tracking file.
209
210        First check if the file was not committed to the db yet (new workout
211        not saved yet) and use the path to the temporary file on the fs.
212        If none is found there, go for the real blob file in the blobs
213        directory
214        """
215        path = None
216        if self.tracking_file:
217            path = self.tracking_file._uncommitted()
218            if path is None:
219                path = self.tracking_file.committed()
220        return path
221
222    @property
223    def fit_file_path(self):
224        """
225        Get the path to the blob file attached as a fit file.
226
227        First check if the file was not committed to the db yet (new workout
228        not saved yet) and use the path to the temporary file on the fs.
229        If none is found there, go for the real blob file in the blobs
230        directory
231        """
232        path = None
233        if self.fit_file:
234            path = self.fit_file._uncommitted()
235            if path is None:
236                path = self.fit_file.committed()
237        return path
238
239    def load_from_file(self):
240        """
241        Check which kind of tracking file we have for this workout, then call
242        the proper method to load info from the tracking file
243        """
244        if self.tracking_filetype == 'gpx':
245            self.load_from_gpx()
246        elif self.tracking_filetype == 'fit':
247            self.load_from_fit()
248
249    def load_from_gpx(self):
250        """
251        Load some information from an attached gpx file. Return True if data
252        had been succesfully loaded, False otherwise
253        """
254        with self.tracking_file.open() as gpx_file:
255            gpx_contents = gpx_file.read()
256            gpx_contents = gpx_contents.decode('utf-8')
257            gpx = gpxpy.parse(gpx_contents)
258            if gpx.tracks:
259                track = gpx.tracks[0]
260                # Start time comes in UTC/GMT/ZULU
261                time_bounds = track.get_time_bounds()
262                self.start = time_bounds.start_time
263                # ensure this datetime start object is timezone-aware
264                self.start = self.start.replace(tzinfo=timezone.utc)
265                # get_duration returns seconds
266                self.duration = timedelta(seconds=track.get_duration())
267                # length_3d returns meters
268                self.distance = Decimal(track.length_3d()) / Decimal(1000.00)
269                ud = track.get_uphill_downhill()
270                self.uphill = Decimal(ud.uphill)
271                self.downhill = Decimal(ud.downhill)
272                # If the user did not provide us with a title, and the gpx has
273                # one, use that
274                if not self.title and track.name:
275                    self.title = track.name
276
277                # Hack to calculate some values from the GPX 1.1 extensions,
278                # using our own parser (gpxpy does not support those yet)
279                tracks = self.parse_gpx()
280                hr = []
281                cad = []
282                atemp = []
283                for t in tracks:
284                    hr += [
285                        d['hr'] for d in tracks[t] if d['hr'] is not None]
286                    cad += [
287                        d['cad'] for d in tracks[t] if d['cad'] is not None]
288                    atemp += [
289                        d['atemp'] for d in tracks[t]
290                        if d['atemp'] is not None]
291
292                if hr:
293                    self.hr_min = Decimal(min(hr))
294                    self.hr_avg = Decimal(sum(hr)) / Decimal(len(hr))
295                    self.hr_max = Decimal(max(hr))
296
297                if cad:
298                    self.cad_min = Decimal(min(cad))
299                    self.cad_avg = Decimal(sum(cad)) / Decimal(len(cad))
300                    self.cad_max = Decimal(max(cad))
301
302                if atemp:
303                    self.atemp_min = Decimal(min(atemp))
304                    self.atemp_avg = Decimal(sum(atemp)) / Decimal(len(atemp))
305                    self.atemp_max = Decimal(max(atemp))
306
307                return True
308
309        return False
310
311    def parse_gpx(self):
312        """
313        Parse the gpx using minidom.
314
315        This method is needed as a workaround to get HR/CAD/TEMP values from
316        gpx 1.1 extensions (gpxpy does not handle them correctly so far)
317        """
318        if not self.has_gpx:
319            # No gpx, nothing to return
320            return {}
321
322        # Get the path to the blob file, first check if the file was not
323        # committed to the db yet (new workout not saved yet) and use the
324        # path to the temporary file on the fs. If none is found there, go
325        # for the final blob file
326        gpx_path = self.tracking_file._p_blob_uncommitted
327        if gpx_path is None:
328            gpx_path = self.tracking_file._p_blob_committed
329
330        # Create a parser, load the gpx and parse the tracks
331        parser = GPXMinidomParser(gpx_path)
332        parser.load_gpx()
333        parser.parse_tracks()
334        return parser.tracks
335
336    def load_from_fit(self):
337        """
338        Try to load data from an ANT-compatible .fit file (if any has been
339        added to this workout).
340
341        "Load data" means:
342
343        1. Copy over the uploaded fit file to self.fit_file, so we can keep
344           that copy around for future use
345
346        2. generate a gpx object from the fit file
347
348        3. save the gpx object as the tracking_file, which then will be used
349           by the current code to display and gather data to be displayed/shown
350           to the user.
351
352        4. Grab some basic info from the fit file and store it in the Workout
353        """
354
355        # we can call load_from_fit afterwards for updates. In such case, check
356        # if the tracking file is a fit file uploaded to override the previous
357        # one. If not, just reuse the existing fit file
358        if self.tracking_filetype == 'fit':
359            # backup the fit file
360            self.fit_file = copy_blob(self.tracking_file)
361
362        # create an instance of our Fit class
363        fit = Fit(self.fit_file_path)
364        fit.load()
365
366        # fit -> gpx and store that as the main tracking file
367        self.tracking_file = create_blob(fit.gpx, 'gpx')
368        self.tracking_filetype = 'gpx'
369
370        # grab the needed data from the fit file, update the workout
371        self.sport = fit.data['sport']
372        self.start = fit.data['start']
373        # ensure this datetime start object is timezone-aware
374        self.start = self.start.replace(tzinfo=timezone.utc)
375        # duration comes in seconds, store a timedelta
376        self.duration = timedelta(seconds=fit.data['duration'])
377        if fit.data['distance']:
378            # distance comes in meters
379            self.distance = Decimal(fit.data['distance']) / Decimal(1000.00)
380        if fit.data['uphill']:
381            self.uphill = Decimal(fit.data['uphill'])
382        if fit.data['downhill']:
383            self.downhill = Decimal(fit.data['downhill'])
384        # If the user did not provide us with a title, build one from the
385        # info in the fit file
386        if not self.title:
387            self.title = fit.name
388
389        if fit.data['max_speed']:
390            self.speed['max'] = mps_to_kmph(fit.data['max_speed'])
391
392        if fit.data['avg_speed']:
393            self.speed['avg'] = mps_to_kmph(fit.data['avg_speed'])
394
395        if fit.data['avg_hr']:
396            self.hr_avg = Decimal(fit.data['avg_hr'])
397            self.hr_min = Decimal(fit.data['min_hr'])
398            self.hr_max = Decimal(fit.data['max_hr'])
399
400        if fit.data['avg_cad']:
401            self.cad_avg = Decimal(fit.data['avg_cad'])
402            self.cad_min = Decimal(fit.data['min_cad'])
403            self.cad_max = Decimal(fit.data['max_cad'])
404
405        if fit.data['avg_atemp']:
406            self.atemp_avg = Decimal(fit.data['avg_atemp'])
407            self.atemp_min = Decimal(fit.data['min_atemp'])
408            self.atemp_max = Decimal(fit.data['max_atemp'])
409
410        return True
411
412    @property
413    def has_tracking_file(self):
414        return self.tracking_file is not None
415
416    @property
417    def has_gpx(self):
418        return self.has_tracking_file and self.tracking_filetype == 'gpx'
419
420    @property
421    def has_fit(self):
422        return self.fit_file is not None
423
424    @property
425    def map_screenshot_name(self):
426        return os.path.join(
427            str(self.owner.uid), str(self.workout_id) + '.png')
428
429    @property
430    def map_screenshot_path(self):
431        current_path = os.path.abspath(os.path.dirname(__file__))
432        return os.path.join(
433            current_path, '../static/maps', self.map_screenshot_name)
434
435    @property
436    def map_screenshot(self):
437        """
438        Return the static path to the screenshot image of the map for
439        this workout (works only for workouts with gps tracking)
440        """
441        if not self.has_gpx:
442            return None
443
444        if not os.path.exists(self.map_screenshot_path):
445            return None
446
447        static_path = os.path.join('static/maps', self.map_screenshot_name)
448        # return a string we can use with request.static_url
449        return 'ow:' + static_path
Note: See TracBrowser for help on using the repository browser.