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

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

Put duration/distance on the workout hash only if those values are there

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