[5ec3a0b] | 1 | |
---|
| 2 | from datetime import datetime, timedelta, timezone |
---|
| 3 | from decimal import Decimal |
---|
| 4 | |
---|
| 5 | import gpxpy |
---|
| 6 | from repoze.folder import Folder |
---|
| 7 | from pyramid.security import Allow, Everyone |
---|
| 8 | from ow.utilities import GPXMinidomParser |
---|
| 9 | |
---|
| 10 | |
---|
| 11 | class 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' |
---|