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

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

(#14) Timezones support:

  • Added pytz as a new dependency, please install it in your existing envs:

pip install pytz

  • Added a timezone attribute to users, to store in which timezone they are, defaults to 'UTC'. Ensure any users you could have in your database have such attribute. You can add it in pshell:

for user in root.users:

user.timezone = 'UTC'

request.tm.commit()

  • Modified schemas/templates/views to let users choose their timezone based on a list of "common" timezones provided by pytz
  • Added two methods to the Workout model so we can get the start and end dates formatted in the appropiate timezone (all datetime objects are stored in UTC)
  • Modified the templates where we show workout dates and times so the new timezone-formatting methods are used.
  • 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 end(self):
65        if not self.duration:
66            return None
67        return self.start + self.duration
68
69    @property
70    def start_date(self):
71        return self.start.strftime('%d/%m/%Y')
72
73    @property
74    def start_time(self):
75        return self.start.strftime('%H:%M')
76
77    def start_in_timezone(self, timezone):
78        """
79        Return a string representation of the start date and time,
80        localized into the given timezone
81        """
82        _start = self.start.astimezone(pytz.timezone(timezone))
83        return _start.strftime('%d/%m/%Y %H:%M (%Z)')
84
85    def end_in_timezone(self, timezone):
86        """
87        Return a string representation of the end date and time,
88        localized into the given timezone
89        """
90        _end = self.end.astimezone(pytz.timezone(timezone))
91        return _end.strftime('%d/%m/%Y %H:%M (%Z)')
92
93    def split_duration(self):
94        hours, remainder = divmod(int(self.duration.total_seconds()), 3600)
95        minutes, seconds = divmod(remainder, 60)
96        return hours, minutes, seconds
97
98    @property
99    def duration_hours(self):
100        return str(self.split_duration()[0]).zfill(2)
101
102    @property
103    def duration_minutes(self):
104        return str(self.split_duration()[1]).zfill(2)
105
106    @property
107    def duration_seconds(self):
108        return str(self.split_duration()[2]).zfill(2)
109
110    @property
111    def rounded_distance(self):
112        """
113        Return rounded value for distance, '-' if the workout has no distance
114        data (weight lifting, martial arts, table tennis, etc)
115        """
116        if self.distance:
117            return round(self.distance, 1)
118        return '-'
119
120    @property
121    def has_hr(self):
122        """
123        True if this workout has heart rate data, False otherwise
124        """
125        data = [self.hr_min, self.hr_max, self.hr_avg]
126        return data.count(None) == 0
127
128    @property
129    def hr(self):
130        """
131        Return a dict with rounded values for hr min, max and average,
132        return None if there is no heart rate data for this workout
133        """
134        if self.has_hr:
135            return {'min': round(self.hr_min),
136                    'max': round(self.hr_max),
137                    'avg': round(self.hr_avg)}
138        return None
139
140    @property
141    def has_cad(self):
142        """
143        True if this workout has cadence data, False otherwise
144        """
145        data = [self.cad_min, self.cad_max, self.cad_avg]
146        return data.count(None) == 0
147
148    @property
149    def cad(self):
150        """
151        Return a dict with rounded values for cadence min, max and average
152        return None if there is no cadence data for this workout
153        """
154        if self.has_cad:
155            return {'min': round(self.cad_min),
156                    'max': round(self.cad_max),
157                    'avg': round(self.cad_avg)}
158        return None
159
160    @property
161    def has_atemp(self):
162        """
163        True if this workout has temperature data, False otherwise
164        """
165        data = [self.atemp_min, self.atemp_max, self.atemp_avg]
166        return data.count(None) == 0
167
168    @property
169    def atemp(self):
170        """
171        Return a dict with rounded values for temperature min, max and average
172        return None if there is no temperature data for this workout
173        """
174        if self.has_atemp:
175            return {'min': round(self.atemp_min),
176                    'max': round(self.atemp_max),
177                    'avg': round(self.atemp_avg)}
178        return None
179
180    def load_from_file(self):
181        """
182        Check which kind of tracking file we have for this workout, then call
183        the proper method to load info from the tracking file
184        """
185        if self.tracking_filetype == 'gpx':
186            self.load_from_gpx()
187
188    def load_from_gpx(self):
189        """
190        Load some information from an attached gpx file. Return True if data
191        had been succesfully loaded, False otherwise
192        """
193        with self.tracking_file.open() as gpx_file:
194            gpx_contents = gpx_file.read()
195            gpx_contents = gpx_contents.decode('utf-8')
196            gpx = gpxpy.parse(gpx_contents)
197            if gpx.tracks:
198                track = gpx.tracks[0]
199                # Start time comes in UTC/GMT/ZULU
200                time_bounds = track.get_time_bounds()
201                self.start = time_bounds.start_time
202                # ensure this datetime start object is timezone-aware
203                self.start = self.start.replace(tzinfo=timezone.utc)
204                # get_duration returns seconds
205                self.duration = timedelta(seconds=track.get_duration())
206                # length_3d returns meters
207                self.distance = Decimal(track.length_3d()) / Decimal(1000.00)
208                ud = track.get_uphill_downhill()
209                self.uphill = Decimal(ud.uphill)
210                self.downhill = Decimal(ud.downhill)
211                # If the user did not provide us with a title, and the gpx has
212                # one, use that
213                if not self.title and track.name:
214                    self.title = track.name
215
216                # Hack to calculate some values from the GPX 1.1 extensions,
217                # using our own parser (gpxpy does not support those yet)
218                tracks = self.parse_gpx()
219                hr = []
220                cad = []
221                atemp = []
222                for t in tracks:
223                    hr += [
224                        d['hr'] for d in tracks[t] if d['hr'] is not None]
225                    cad += [
226                        d['cad'] for d in tracks[t] if d['cad'] is not None]
227                    atemp += [
228                        d['atemp'] for d in tracks[t]
229                        if d['atemp'] is not None]
230
231                if hr:
232                    self.hr_min = Decimal(min(hr))
233                    self.hr_avg = Decimal(sum(hr)) / Decimal(len(hr))
234                    self.hr_max = Decimal(max(hr))
235
236                if cad:
237                    self.cad_min = Decimal(min(cad))
238                    self.cad_avg = Decimal(sum(cad)) / Decimal(len(cad))
239                    self.cad_max = Decimal(max(cad))
240
241                if atemp:
242                    self.atemp_min = Decimal(min(atemp))
243                    self.atemp_avg = Decimal(sum(atemp)) / Decimal(len(atemp))
244                    self.atemp_max = Decimal(max(atemp))
245
246                return True
247
248        return False
249
250    def parse_gpx(self):
251        """
252        Parse the gpx using minidom.
253
254        This method is needed as a workaround to get HR/CAD/TEMP values from
255        gpx 1.1 extensions (gpxpy does not handle them correctly so far)
256        """
257        if not self.has_gpx:
258            # No gpx, nothing to return
259            return {}
260
261        # Get the path to the blob file, first check if the file was not
262        # committed to the db yet (new workout not saved yet) and use the
263        # path to the temporary file on the fs. If none is found there, go
264        # for the final blob file
265        gpx_path = self.tracking_file._p_blob_uncommitted
266        if gpx_path is None:
267            gpx_path = self.tracking_file._p_blob_committed
268
269        # Create a parser, load the gpx and parse the tracks
270        parser = GPXMinidomParser(gpx_path)
271        parser.load_gpx()
272        parser.parse_tracks()
273        return parser.tracks
274
275    @property
276    def has_tracking_file(self):
277        return self.tracking_file is not None
278
279    @property
280    def has_gpx(self):
281        return self.has_tracking_file and self.tracking_filetype == 'gpx'
Note: See TracBrowser for help on using the repository browser.