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

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

(#7) Added several methods to the User model to gather some stats (yearly,

monthly, weekly).

Added two new utilities:

  • timedelta_to_hms (so we can print timedelta objects properly in template code)
  • get_week_days (returns a list of datetime objects for the days in the same week as a given day)

Added a template_helpers module, containing code that affects template
rendering.

Added timedelta_to_hms as a global to the default template rendering context

Refactored some code in the Workout model so it uses timedelta_to_hms instead
of running the same code twice.

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