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

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

Imported sources from the old python2-only repository:

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