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

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

Tests and coverage catch up.

  • Property mode set to 100644
File size: 9.6 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 rounded_distance(self):
116        """
117        Return rounded value for distance, '-' if the workout has no distance
118        data (weight lifting, martial arts, table tennis, etc)
119        """
120        if self.distance:
121            return round(self.distance, 1)
122        return '-'
123
124    @property
125    def has_hr(self):
126        """
127        True if this workout has heart rate data, False otherwise
128        """
129        data = [self.hr_min, self.hr_max, self.hr_avg]
130        return data.count(None) == 0
131
132    @property
133    def hr(self):
134        """
135        Return a dict with rounded values for hr min, max and average,
136        return None if there is no heart rate data for this workout
137        """
138        if self.has_hr:
139            return {'min': round(self.hr_min),
140                    'max': round(self.hr_max),
141                    'avg': round(self.hr_avg)}
142        return None
143
144    @property
145    def has_cad(self):
146        """
147        True if this workout has cadence data, False otherwise
148        """
149        data = [self.cad_min, self.cad_max, self.cad_avg]
150        return data.count(None) == 0
151
152    @property
153    def cad(self):
154        """
155        Return a dict with rounded values for cadence min, max and average
156        return None if there is no cadence data for this workout
157        """
158        if self.has_cad:
159            return {'min': round(self.cad_min),
160                    'max': round(self.cad_max),
161                    'avg': round(self.cad_avg)}
162        return None
163
164    @property
165    def has_atemp(self):
166        """
167        True if this workout has temperature data, False otherwise
168        """
169        data = [self.atemp_min, self.atemp_max, self.atemp_avg]
170        return data.count(None) == 0
171
172    @property
173    def atemp(self):
174        """
175        Return a dict with rounded values for temperature min, max and average
176        return None if there is no temperature data for this workout
177        """
178        if self.has_atemp:
179            return {'min': round(self.atemp_min),
180                    'max': round(self.atemp_max),
181                    'avg': round(self.atemp_avg)}
182        return None
183
184    def load_from_file(self):
185        """
186        Check which kind of tracking file we have for this workout, then call
187        the proper method to load info from the tracking file
188        """
189        if self.tracking_filetype == 'gpx':
190            self.load_from_gpx()
191
192    def load_from_gpx(self):
193        """
194        Load some information from an attached gpx file. Return True if data
195        had been succesfully loaded, False otherwise
196        """
197        with self.tracking_file.open() as gpx_file:
198            gpx_contents = gpx_file.read()
199            gpx_contents = gpx_contents.decode('utf-8')
200            gpx = gpxpy.parse(gpx_contents)
201            if gpx.tracks:
202                track = gpx.tracks[0]
203                # Start time comes in UTC/GMT/ZULU
204                time_bounds = track.get_time_bounds()
205                self.start = time_bounds.start_time
206                # ensure this datetime start object is timezone-aware
207                self.start = self.start.replace(tzinfo=timezone.utc)
208                # get_duration returns seconds
209                self.duration = timedelta(seconds=track.get_duration())
210                # length_3d returns meters
211                self.distance = Decimal(track.length_3d()) / Decimal(1000.00)
212                ud = track.get_uphill_downhill()
213                self.uphill = Decimal(ud.uphill)
214                self.downhill = Decimal(ud.downhill)
215                # If the user did not provide us with a title, and the gpx has
216                # one, use that
217                if not self.title and track.name:
218                    self.title = track.name
219
220                # Hack to calculate some values from the GPX 1.1 extensions,
221                # using our own parser (gpxpy does not support those yet)
222                tracks = self.parse_gpx()
223                hr = []
224                cad = []
225                atemp = []
226                for t in tracks:
227                    hr += [
228                        d['hr'] for d in tracks[t] if d['hr'] is not None]
229                    cad += [
230                        d['cad'] for d in tracks[t] if d['cad'] is not None]
231                    atemp += [
232                        d['atemp'] for d in tracks[t]
233                        if d['atemp'] is not None]
234
235                if hr:
236                    self.hr_min = Decimal(min(hr))
237                    self.hr_avg = Decimal(sum(hr)) / Decimal(len(hr))
238                    self.hr_max = Decimal(max(hr))
239
240                if cad:
241                    self.cad_min = Decimal(min(cad))
242                    self.cad_avg = Decimal(sum(cad)) / Decimal(len(cad))
243                    self.cad_max = Decimal(max(cad))
244
245                if atemp:
246                    self.atemp_min = Decimal(min(atemp))
247                    self.atemp_avg = Decimal(sum(atemp)) / Decimal(len(atemp))
248                    self.atemp_max = Decimal(max(atemp))
249
250                return True
251
252        return False
253
254    def parse_gpx(self):
255        """
256        Parse the gpx using minidom.
257
258        This method is needed as a workaround to get HR/CAD/TEMP values from
259        gpx 1.1 extensions (gpxpy does not handle them correctly so far)
260        """
261        if not self.has_gpx:
262            # No gpx, nothing to return
263            return {}
264
265        # Get the path to the blob file, first check if the file was not
266        # committed to the db yet (new workout not saved yet) and use the
267        # path to the temporary file on the fs. If none is found there, go
268        # for the final blob file
269        gpx_path = self.tracking_file._p_blob_uncommitted
270        if gpx_path is None:
271            gpx_path = self.tracking_file._p_blob_committed
272
273        # Create a parser, load the gpx and parse the tracks
274        parser = GPXMinidomParser(gpx_path)
275        parser.load_gpx()
276        parser.parse_tracks()
277        return parser.tracks
278
279    @property
280    def has_tracking_file(self):
281        return self.tracking_file is not None
282
283    @property
284    def has_gpx(self):
285        return self.has_tracking_file and self.tracking_filetype == 'gpx'
Note: See TracBrowser for help on using the repository browser.