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

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

Show a capture of the workout map, as an image, in the dashboard:

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