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

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

(#39) Do not allow duplicated workouts by default when uploading track files.
We still allow users to add duplicates if they want, by checking a checkbox
we show in the upload workout form when we find a possible duplicate.

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