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

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

Show properly formatted duration for workouts (hours:minutes:seconds)

even for workouts which duration contains microseconds info.

  • Property mode set to 100644
File size: 9.8 KB
Line 
1
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
9from ow.utilities import GPXMinidomParser
10
11
12class Workout(Folder):
13
14    __parent__ = __name__ = None
15
16    def __acl__(self):
17        """
18        If the workout is owned by a given user, only that user have access to
19        it (for now). If not, everybody can view it, only admins can edit it.
20        """
21        # Default permissions
22        permissions = [
23            (Allow, Everyone, 'view'),
24            (Allow, 'group:admins', 'edit')
25        ]
26
27        uid = getattr(self.__parent__, 'uid', None)
28        if uid is not None:
29            # Change permissions in case this workout has an owner
30            permissions = [
31                (Allow, str(uid), 'view'),
32                (Allow, str(uid), 'edit'),
33            ]
34        return permissions
35
36    def __init__(self, **kw):
37        super(Workout, self).__init__()
38        # we do store datetime objects with UTC timezone
39        self.start = kw.get('start', datetime.now(timezone.utc))
40        self.sport = kw.get('sport', 'unknown')  # string
41        self.title = kw.get('title', '')  # unicode string
42        self.notes = kw.get('notes', '')  # unicode string
43        self.duration = kw.get('duration', None)  # a timedelta object
44        self.distance = kw.get('distance', None)  # kilometers, Decimal
45        self.hr_min = kw.get('hr_min', None)  # bpm, Decimal
46        self.hr_max = kw.get('hr_max', None)  # bpm, Decimal
47        self.hr_avg = kw.get('hr_avg', None)  # bpm, Decimal
48        self.uphill = kw.get('uphill', None)
49        self.downhill = kw.get('downhill', None)
50        self.cad_min = kw.get('cad_min', None)
51        self.cad_max = kw.get('cad_max', None)
52        self.cad_avg = kw.get('cad_avg', None)
53        self.atemp_min = kw.get('atemp_min', None)
54        self.atemp_max = kw.get('atemp_max', None)
55        self.atemp_avg = kw.get('atemp_avg', None)
56        self.tracking_file = kw.get('tracking_file', None)  # Blob
57        self.tracking_filetype = ''  # unicode string
58
59    @property
60    def workout_id(self):
61        return self.__name__
62
63    @property
64    def owner(self):
65        return self.__parent__
66
67    @property
68    def end(self):
69        if not self.duration:
70            return None
71        return self.start + self.duration
72
73    @property
74    def start_date(self):
75        return self.start.strftime('%d/%m/%Y')
76
77    @property
78    def start_time(self):
79        return self.start.strftime('%H:%M')
80
81    def start_in_timezone(self, timezone):
82        """
83        Return a string representation of the start date and time,
84        localized into the given timezone
85        """
86        _start = self.start.astimezone(pytz.timezone(timezone))
87        return _start.strftime('%d/%m/%Y %H:%M (%Z)')
88
89    def end_in_timezone(self, timezone):
90        """
91        Return a string representation of the end date and time,
92        localized into the given timezone
93        """
94        _end = self.end.astimezone(pytz.timezone(timezone))
95        return _end.strftime('%d/%m/%Y %H:%M (%Z)')
96
97    def split_duration(self):
98        hours, remainder = divmod(int(self.duration.total_seconds()), 3600)
99        minutes, seconds = divmod(remainder, 60)
100        return hours, minutes, seconds
101
102    @property
103    def duration_hours(self):
104        return str(self.split_duration()[0]).zfill(2)
105
106    @property
107    def duration_minutes(self):
108        return str(self.split_duration()[1]).zfill(2)
109
110    @property
111    def duration_seconds(self):
112        return str(self.split_duration()[2]).zfill(2)
113
114    @property
115    def _duration(self):
116        return ':'.join(
117            [self.duration_hours, self.duration_minutes, self.duration_seconds]
118        )
119
120    @property
121    def rounded_distance(self):
122        """
123        Return rounded value for distance, '-' if the workout has no distance
124        data (weight lifting, martial arts, table tennis, etc)
125        """
126        if self.distance:
127            return round(self.distance, 1)
128        return '-'
129
130    @property
131    def has_hr(self):
132        """
133        True if this workout has heart rate data, False otherwise
134        """
135        data = [self.hr_min, self.hr_max, self.hr_avg]
136        return data.count(None) == 0
137
138    @property
139    def hr(self):
140        """
141        Return a dict with rounded values for hr min, max and average,
142        return None if there is no heart rate data for this workout
143        """
144        if self.has_hr:
145            return {'min': round(self.hr_min),
146                    'max': round(self.hr_max),
147                    'avg': round(self.hr_avg)}
148        return None
149
150    @property
151    def has_cad(self):
152        """
153        True if this workout has cadence data, False otherwise
154        """
155        data = [self.cad_min, self.cad_max, self.cad_avg]
156        return data.count(None) == 0
157
158    @property
159    def cad(self):
160        """
161        Return a dict with rounded values for cadence min, max and average
162        return None if there is no cadence data for this workout
163        """
164        if self.has_cad:
165            return {'min': round(self.cad_min),
166                    'max': round(self.cad_max),
167                    'avg': round(self.cad_avg)}
168        return None
169
170    @property
171    def has_atemp(self):
172        """
173        True if this workout has temperature data, False otherwise
174        """
175        data = [self.atemp_min, self.atemp_max, self.atemp_avg]
176        return data.count(None) == 0
177
178    @property
179    def atemp(self):
180        """
181        Return a dict with rounded values for temperature min, max and average
182        return None if there is no temperature data for this workout
183        """
184        if self.has_atemp:
185            return {'min': round(self.atemp_min),
186                    'max': round(self.atemp_max),
187                    'avg': round(self.atemp_avg)}
188        return None
189
190    def load_from_file(self):
191        """
192        Check which kind of tracking file we have for this workout, then call
193        the proper method to load info from the tracking file
194        """
195        if self.tracking_filetype == 'gpx':
196            self.load_from_gpx()
197
198    def load_from_gpx(self):
199        """
200        Load some information from an attached gpx file. Return True if data
201        had been succesfully loaded, False otherwise
202        """
203        with self.tracking_file.open() as gpx_file:
204            gpx_contents = gpx_file.read()
205            gpx_contents = gpx_contents.decode('utf-8')
206            gpx = gpxpy.parse(gpx_contents)
207            if gpx.tracks:
208                track = gpx.tracks[0]
209                # Start time comes in UTC/GMT/ZULU
210                time_bounds = track.get_time_bounds()
211                self.start = time_bounds.start_time
212                # ensure this datetime start object is timezone-aware
213                self.start = self.start.replace(tzinfo=timezone.utc)
214                # get_duration returns seconds
215                self.duration = timedelta(seconds=track.get_duration())
216                # length_3d returns meters
217                self.distance = Decimal(track.length_3d()) / Decimal(1000.00)
218                ud = track.get_uphill_downhill()
219                self.uphill = Decimal(ud.uphill)
220                self.downhill = Decimal(ud.downhill)
221                # If the user did not provide us with a title, and the gpx has
222                # one, use that
223                if not self.title and track.name:
224                    self.title = track.name
225
226                # Hack to calculate some values from the GPX 1.1 extensions,
227                # using our own parser (gpxpy does not support those yet)
228                tracks = self.parse_gpx()
229                hr = []
230                cad = []
231                atemp = []
232                for t in tracks:
233                    hr += [
234                        d['hr'] for d in tracks[t] if d['hr'] is not None]
235                    cad += [
236                        d['cad'] for d in tracks[t] if d['cad'] is not None]
237                    atemp += [
238                        d['atemp'] for d in tracks[t]
239                        if d['atemp'] is not None]
240
241                if hr:
242                    self.hr_min = Decimal(min(hr))
243                    self.hr_avg = Decimal(sum(hr)) / Decimal(len(hr))
244                    self.hr_max = Decimal(max(hr))
245
246                if cad:
247                    self.cad_min = Decimal(min(cad))
248                    self.cad_avg = Decimal(sum(cad)) / Decimal(len(cad))
249                    self.cad_max = Decimal(max(cad))
250
251                if atemp:
252                    self.atemp_min = Decimal(min(atemp))
253                    self.atemp_avg = Decimal(sum(atemp)) / Decimal(len(atemp))
254                    self.atemp_max = Decimal(max(atemp))
255
256                return True
257
258        return False
259
260    def parse_gpx(self):
261        """
262        Parse the gpx using minidom.
263
264        This method is needed as a workaround to get HR/CAD/TEMP values from
265        gpx 1.1 extensions (gpxpy does not handle them correctly so far)
266        """
267        if not self.has_gpx:
268            # No gpx, nothing to return
269            return {}
270
271        # Get the path to the blob file, first check if the file was not
272        # committed to the db yet (new workout not saved yet) and use the
273        # path to the temporary file on the fs. If none is found there, go
274        # for the final blob file
275        gpx_path = self.tracking_file._p_blob_uncommitted
276        if gpx_path is None:
277            gpx_path = self.tracking_file._p_blob_committed
278
279        # Create a parser, load the gpx and parse the tracks
280        parser = GPXMinidomParser(gpx_path)
281        parser.load_gpx()
282        parser.parse_tracks()
283        return parser.tracks
284
285    @property
286    def has_tracking_file(self):
287        return self.tracking_file is not None
288
289    @property
290    def has_gpx(self):
291        return self.has_tracking_file and self.tracking_filetype == 'gpx'
Note: See TracBrowser for help on using the repository browser.