source: OpenWorkouts-current/ow/models/user.py @ 778d53d

current
Last change on this file since 778d53d was 778d53d, checked in by Borja Lopez <borja@…>, 5 years ago

(#7) Show per-sport stats in the profile page:

  • Show a dropdown list of sports for which the user has activities. By default we choose the sport with most activities.
  • Show a dropdown list of years for which the user has activities. By default we show stats for the current year. If the user picks up a different year, we show the totals (distance, time, elevation, number of workouts) for that year.
  • Show the totals of all time for the chosen sport
  • Property mode set to 100644
File size: 14.8 KB
Line 
1from decimal import Decimal
2from datetime import datetime, timedelta, timezone
3from uuid import uuid1
4from operator import attrgetter
5
6import bcrypt
7from repoze.folder import Folder
8from pyramid.security import Allow, Deny, Everyone, ALL_PERMISSIONS
9
10from ow.catalog import get_catalog, reindex_object
11from ow.utilities import get_week_days, get_month_week_number
12
13
14class User(Folder):
15
16    __parent__ = __name__ = None
17
18    def __acl__(self):
19        permissions = [
20            (Allow, str(self.uid), 'view'),
21            (Allow, str(self.uid), 'edit'),
22            (Deny, Everyone, ALL_PERMISSIONS),
23        ]
24        return permissions
25
26    def __init__(self, **kw):
27        self.uid = kw.get('uid', uuid1())
28        self.nickname = kw.get('nickname', '')
29        self.firstname = kw.get('firstname', '')
30        self.lastname = kw.get('lastname', '')
31        self.email = kw.get('email', '')
32        self.bio = kw.get('bio', '')
33        self.birth_date = kw.get('birth_date', None)
34        self.height = kw.get('height', None)
35        self.weight = kw.get('weight', None)
36        self.gender = kw.get('gender', 'robot')
37        self.picture = kw.get('picture', None)  # blob
38        self.timezone = kw.get('timezone', 'UTC')
39        self.locale = kw.get('locale', 'en')
40        self.__password = None
41        self.last_workout_id = 0
42        # has this user verified his account?
43        self.verified = False
44        self.verification_token = None
45        self.verification_tokens_sent = 0
46        super(User, self).__init__()
47
48    def __str__(self):
49        return u'User: %s (%s)' % (self.uid, self.email)
50
51    def __repr__(self):
52        return u'<%s.%s: %s (%s)>' % (
53            self.__class__.__module__,
54            self.__class__.__name__,
55            self.uid, self.email
56        )
57
58    @property
59    def password(self):
60        return self.__password
61
62    @password.setter
63    def password(self, password=None):
64        """
65        Sets a password for the user, hashing with bcrypt.
66        """
67        password = password.encode('utf-8')
68        self.__password = bcrypt.hashpw(password, bcrypt.gensalt())
69
70    def check_password(self, password):
71        """
72        Check a plain text password against a hashed one
73        """
74        hashed = bcrypt.hashpw(password.encode('utf-8'), self.__password)
75        return hashed == self.__password
76
77    @property
78    def fullname(self):
79        """
80        Naive implementation of fullname: firstname + lastname
81        """
82        return u'%s %s' % (self.firstname, self.lastname)
83
84    def add_workout(self, workout):
85        # This returns the main catalog at the root folder
86        catalog = get_catalog(self)
87        self.last_workout_id += 1
88        workout_id = str(self.last_workout_id)
89        self[workout_id] = workout
90        reindex_object(catalog, workout)
91
92    def workouts(self, year=None, month=None, week=None):
93        """
94        Return this user workouts, sorted by date, from newer to older
95        """
96        workouts = self.values()
97        if year:
98            workouts = [w for w in workouts if w.start.year == year]
99            if month:
100                workouts = [w for w in workouts if w.start.month == month]
101            if week:
102                week = int(week)
103                workouts = [
104                    w for w in workouts if w.start.isocalendar()[1] == week]
105        workouts = sorted(workouts, key=attrgetter('start'))
106        workouts.reverse()
107        return workouts
108
109    def workout_ids(self):
110        return self.keys()
111
112    @property
113    def num_workouts(self):
114        return len(self.workout_ids())
115
116    @property
117    def favorite_sport(self):
118        """
119        Return which sport is the one with most workouts for this user.
120        In case of more than one sport with the maximum number of workouts,
121        return the first in reversed alphabetical ordering
122        """
123        sports = {}
124        for w in self.workouts():
125            if w.sport not in sports.keys():
126                sports[w.sport] = 0
127            sports[w.sport] += 1
128        _sports = sorted(sports.items(), reverse=True,
129                         key=lambda x: (x[1], x[0]))
130        if _sports:
131            return _sports[0][0]
132        return None
133
134    @property
135    def activity_sports(self):
136        return sorted(list(set(w.sport for w in self.workouts())))
137
138    @property
139    def activity_years(self):
140        return sorted(list(set(w.start.year for w in self.workouts())),
141                      reverse=True)
142
143    def activity_months(self, year):
144        months = set(
145            w.start.month for w in self.workouts() if w.start.year == year)
146        return sorted(list(months))
147
148    @property
149    def activity_dates_tree(self):
150        """
151        Return a dict containing information about the activity for this
152        user.
153
154        Example:
155
156        {
157            2019: {
158                1: {'cycling': 12, 'running': 1}
159            },
160            2018: {
161                1: {'cycling': 10, 'running': 3},
162                2: {'cycling': 14, 'swimming': 5}
163            }
164        }
165        """
166        tree = {}
167        for workout in self.workouts():
168            year = workout.start.year
169            month = workout.start.month
170            sport = workout.sport
171            if year not in tree:
172                tree[year] = {}
173            if month not in tree[year]:
174                tree[year][month] = {}
175            if sport not in tree[year][month]:
176                tree[year][month][sport] = 0
177            tree[year][month][sport] += 1
178        return tree
179
180    def stats(self, year=None, month=None):
181        year = year or datetime.now().year
182        stats = {
183            'workouts': 0,
184            'time': timedelta(seconds=0),
185            'distance': Decimal(0),
186            'elevation': Decimal(0),
187            'sports': {}
188        }
189
190        for workout in self.workouts(year=year, month=month):
191            stats['workouts'] += 1
192            stats['time'] += workout.duration or timedelta(seconds=0)
193            stats['distance'] += workout.distance or Decimal(0)
194            stats['elevation'] += workout.uphill or Decimal(0)
195
196            if workout.sport not in stats['sports']:
197                stats['sports'][workout.sport] = {
198                    'workouts': 0,
199                    'time': timedelta(seconds=0),
200                    'distance': Decimal(0),
201                    'elevation': Decimal(0),
202                }
203
204            stats['sports'][workout.sport]['workouts'] += 1
205            stats['sports'][workout.sport]['time'] += (
206                workout.duration or timedelta(0))
207            stats['sports'][workout.sport]['distance'] += (
208                workout.distance or Decimal(0))
209            stats['sports'][workout.sport]['elevation'] += (
210                workout.uphill or Decimal(0))
211
212        return stats
213
214    def get_week_stats(self, day):
215        """
216        Return some stats for the week the given day is in.
217        """
218        week = get_week_days(day)
219
220        # filter workouts
221        workouts = []
222        for workout in self.workouts():
223            if week[0].date() <= workout.start.date() <= week[-1].date():
224                workouts.append(workout)
225
226        # build stats
227        stats = {}
228        for week_day in week:
229            stats[week_day] = {
230                'workouts': 0,
231                'time': timedelta(0),
232                'distance': Decimal(0),
233                'elevation': Decimal(0),
234                'sports': {}
235            }
236            for workout in workouts:
237                if workout.start.date() == week_day.date():
238                    day = stats[week_day]  # less typing, avoid long lines
239                    day['workouts'] += 1
240                    day['time'] += workout.duration or timedelta(seconds=0)
241                    day['distance'] += workout.distance or Decimal(0)
242                    day['elevation'] += workout.uphill or Decimal(0)
243                    if workout.sport not in day['sports']:
244                        day['sports'][workout.sport] = {
245                            'workouts': 0,
246                            'time': timedelta(seconds=0),
247                            'distance': Decimal(0),
248                            'elevation': Decimal(0),
249                        }
250                    day['sports'][workout.sport]['workouts'] += 1
251                    day['sports'][workout.sport]['time'] += (
252                        workout.duration or timedelta(0))
253                    day['sports'][workout.sport]['distance'] += (
254                        workout.distance or Decimal(0))
255                    day['sports'][workout.sport]['elevation'] += (
256                        workout.uphill or Decimal(0))
257
258        return stats
259
260    @property
261    def week_stats(self):
262        """
263        Helper that returns the week stats for the current week
264        """
265        return self.get_week_stats(datetime.now(timezone.utc))
266
267    @property
268    def week_totals(self):
269        week_stats = self.week_stats
270        return {
271            'distance': sum([week_stats[t]['distance'] for t in week_stats]),
272            'time': sum([week_stats[t]['time'] for t in week_stats],
273                        timedelta())
274        }
275
276    @property
277    def yearly_stats(self):
278        """
279        Return per-month stats for the last 12 months
280        """
281        # set the boundaries for looking for workouts afterwards,
282        # we need the current date as the "end date" and one year
283        # ago from that date. Then we set the start at the first
284        # day of that month.
285        end = datetime.now(timezone.utc)
286        start = (end - timedelta(days=365)).replace(day=1)
287
288        # build the stats, populating first the dict with empty values
289        # for each month.
290        stats = {}
291        for days in range((end - start).days):
292            day = (start + timedelta(days=days)).date()
293            if (day.year, day.month) not in stats.keys():
294                stats[(day.year, day.month)] = {
295                    'workouts': 0,
296                    'time': timedelta(0),
297                    'distance': Decimal(0),
298                    'elevation': Decimal(0),
299                    'sports': {}
300                }
301
302        # now loop over workouts, filtering and then adding stats to the
303        # proper place
304        for workout in self.workouts():
305            if start.date() <= workout.start.date() <= end.date():
306                # less typing, avoid long lines
307                month = stats[
308                    (workout.start.date().year, workout.start.date().month)]
309                month['workouts'] += 1
310                month['time'] += workout.duration or timedelta(seconds=0)
311                month['distance'] += workout.distance or Decimal(0)
312                month['elevation'] += workout.uphill or Decimal(0)
313                if workout.sport not in month['sports']:
314                    month['sports'][workout.sport] = {
315                        'workouts': 0,
316                        'time': timedelta(seconds=0),
317                        'distance': Decimal(0),
318                        'elevation': Decimal(0),
319                    }
320                month['sports'][workout.sport]['workouts'] += 1
321                month['sports'][workout.sport]['time'] += (
322                    workout.duration or timedelta(0))
323                month['sports'][workout.sport]['distance'] += (
324                    workout.distance or Decimal(0))
325                month['sports'][workout.sport]['elevation'] += (
326                    workout.uphill or Decimal(0))
327
328        return stats
329
330    @property
331    def weekly_year_stats(self):
332        """
333        Return per-week stats for the last 12 months
334        """
335        # set the boundaries for looking for workouts afterwards,
336        # we need the current date as the "end date" and one year
337        # ago from that date. Then we set the start at the first
338        # day of that month.
339        end = datetime.now(timezone.utc)
340        start = (end - timedelta(days=365)).replace(day=1)
341
342        stats = {}
343
344        # first initialize the stats dict
345        for days in range((end - start).days):
346            day = (start + timedelta(days=days)).date()
347            week = day.isocalendar()[1]
348            month_week = get_month_week_number(day)
349            key = (day.year, day.month, week, month_week)
350            if key not in stats.keys():
351                stats[key] = {
352                    'workouts': 0,
353                    'time': timedelta(0),
354                    'distance': Decimal(0),
355                    'elevation': Decimal(0),
356                    'sports': {}
357                }
358
359        # now loop over the workouts, filtering and then adding stats
360        # to the proper place
361        for workout in self.workouts():
362            if start.date() <= workout.start.date() <= end.date():
363                # less typing, avoid long lines
364                start_date = workout.start.date()
365                week = start_date.isocalendar()[1]
366                month_week = get_month_week_number(start_date)
367                week = stats[(start_date.year,
368                              start_date.month,
369                              week,
370                              month_week)]
371
372                week['workouts'] += 1
373                week['time'] += workout.duration or timedelta(seconds=0)
374                week['distance'] += workout.distance or Decimal(0)
375                week['elevation'] += workout.uphill or Decimal(0)
376                if workout.sport not in week['sports']:
377                    week['sports'][workout.sport] = {
378                        'workouts': 0,
379                        'time': timedelta(seconds=0),
380                        'distance': Decimal(0),
381                        'elevation': Decimal(0),
382                    }
383                week['sports'][workout.sport]['workouts'] += 1
384                week['sports'][workout.sport]['time'] += (
385                    workout.duration or timedelta(0))
386                week['sports'][workout.sport]['distance'] += (
387                    workout.distance or Decimal(0))
388                week['sports'][workout.sport]['elevation'] += (
389                    workout.uphill or Decimal(0))
390
391        return stats
392
393    def sport_totals(self, sport=None, year=None):
394        """
395        Return totals for this user, filtered by sport.
396
397        If no sport is passed, the favorite sport is picked up
398
399        If the additional parameter year is passed, show stats only
400        for that year
401        """
402        totals = {
403            'workouts': 0,
404            'time': timedelta(0),
405            'distance': Decimal(0),
406            'elevation': Decimal(0),
407        }
408        if self.activity_sports:
409            sport = sport or self.favorite_sport
410            for workout in self.workouts():
411                if workout.sport == sport:
412                    if year is None or workout.start.year == year:
413                        totals['workouts'] += 1
414                        totals['time'] += workout.duration or timedelta(0)
415                        totals['distance'] += workout.distance or Decimal(0)
416                        totals['elevation'] += workout.uphill or Decimal(0)
417        return totals
Note: See TracBrowser for help on using the repository browser.