source: OpenWorkouts-current/ow/views/user.py @ 02b96c5

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

(#7) Added calendar heatmap chart to the profile page.

The calendar shows the current month, each day without a workout represented
by a light grey square, each day with workout data in a red/pink color, picked
up from a gradient generated based on the app main colors, and calculated based
on the amount of workout time for the day.

A tooltip is shown on hover with more info (only the total workouts time for
now)

  • Property mode set to 100644
File size: 20.1 KB
Line 
1import json
2from datetime import datetime, timezone, timedelta
3from decimal import Decimal
4from io import BytesIO
5
6from pyramid.httpexceptions import HTTPFound, HTTPNotFound
7from pyramid.view import view_config
8from pyramid.security import remember, forget
9from pyramid.response import Response
10from pyramid.i18n import TranslationStringFactory, get_localizer
11from pyramid_simpleform import Form, State
12from pytz import common_timezones
13from PIL import Image
14
15from ..models.user import User
16from ..schemas.user import (
17    UserProfileSchema,
18    ChangePasswordSchema,
19    SignUpSchema,
20)
21from ..models.root import OpenWorkouts
22from ..views.renderers import OWFormRenderer
23from ..utilities import (
24    timedelta_to_hms,
25    get_verification_token,
26    get_gender_names,
27    get_available_locale_names,
28    get_month_names,
29    get_week_day_names
30)
31from ..mail import send_verification_email
32
33_ = TranslationStringFactory('OpenWorkouts')
34month_name = get_month_names()
35weekday_name = get_week_day_names()
36
37
38@view_config(context=OpenWorkouts)
39def dashboard_redirect(context, request):
40    """
41    Send the user to his dashboard when accesing the root object,
42    send to the login page if the user is not logged in.
43    """
44    if request.authenticated_userid:
45        user = request.root.get_user_by_uid(request.authenticated_userid)
46        if user:
47            return HTTPFound(location=request.resource_url(user))
48        else:
49            # an authenticated user session, for an user that does not exist
50            # anymore, logout!
51            return HTTPFound(location=request.resource_url(context, 'logout'))
52    return HTTPFound(location=request.resource_url(context, 'login'))
53
54
55@view_config(
56    context=OpenWorkouts,
57    name='login',
58    renderer='ow:templates/login.pt')
59def login(context, request):
60    # messages is a dict of pre-defined messages we would need to show to the
61    # user when coming back to the login page after certain actions
62    messages = {
63        'already-verified': _('User has been verified already'),
64        'link-sent': _('Verification link sent, please check your inbox'),
65        'max-tokens-sent': _(
66            'We already sent you the verification link more than three times')
67    }
68    message = request.GET.get('message', '')
69    if message:
70        message = messages.get(message, '')
71    email = request.GET.get('email', '')
72    password = ''
73    return_to = request.params.get('return_to')
74    redirect_url = return_to or request.resource_url(request.root)
75    # If the user still has to verify the account, this will be set to the
76    # proper link to re-send the verification email
77    resend_verify_link = None
78
79    if 'submit' in request.POST:
80        email = request.POST.get('email', None)
81        user = context.get_user_by_email(email)
82        if user:
83            if user.verified:
84                password = request.POST.get('password', None)
85                if password is not None and user.check_password(password):
86                    # look for the value of locale for this user, to set the
87                    # LOCALE cookie, so the UI appears on the pre-selected lang
88                    default_locale = request.registry.settings.get(
89                        'pyramid.default_locale_name')
90                    locale = getattr(user, 'locale', default_locale)
91                    request.response.set_cookie('_LOCALE_', locale)
92                    # log in the user and send back to the place he wanted to
93                    # visit
94                    headers = remember(request, str(user.uid))
95                    request.response.headers.extend(headers)
96                    redirect_url = return_to or request.resource_url(user)
97                    return HTTPFound(location=redirect_url,
98                                     headers=request.response.headers)
99                else:
100                    message = _('Wrong password')
101            else:
102                message = _('You have to verify your account first')
103                resend_verify_link = request.resource_url(
104                    user, 'resend-verification-link'
105                )
106        else:
107            message = _('Wrong email address')
108
109    return {
110        'message': message,
111        'email': email,
112        'password': password,
113        'redirect_url': redirect_url,
114        'resend_verify_link': resend_verify_link
115    }
116
117
118@view_config(context=OpenWorkouts, name='logout')
119def logout(context, request):
120    request.response.delete_cookie('_LOCALE_')
121    headers = forget(request)
122    request.response.headers.extend(headers)
123    return HTTPFound(location=request.resource_url(context),
124                     headers=request.response.headers)
125
126
127@view_config(
128    context=OpenWorkouts,
129    name='signup',
130    renderer='ow:templates/signup.pt')
131def signup(context, request):
132    state = State(emails=context.lowercase_emails,
133                  names=context.lowercase_nicknames)
134    form = Form(request, schema=SignUpSchema(), state=state)
135
136    if 'submit' in request.POST and form.validate():
137        user = form.bind(User(), exclude=['password_confirm'])
138        user.verified = False
139        user.verification_token = get_verification_token()
140        context.add_user(user)
141        # send a verification link to the user email address
142        send_verification_email(request, user)
143        user.verification_tokens_sent += 1
144        # Send to login
145        return HTTPFound(location=request.resource_url(context))
146
147    return {
148        'form': OWFormRenderer(form)
149    }
150
151
152@view_config(
153    context=User,
154    name="verify",
155    renderer='ow:templates/verify.pt')
156def verify(context, request):
157    redirect_url = request.resource_url(context)
158
159    # user has been verified already, send to dashboard
160    if getattr(context, 'verified', False):
161        return HTTPFound(location=redirect_url)
162
163    # Look for a verification token, then check if we can verify the user with
164    # that token
165    verified = len(request.subpath) > 0
166    token = getattr(context, 'verification_token', False)
167    verified = verified and token and str(token) == request.subpath[0]
168    if verified:
169        # verified, log in automatically and send to the dashboard
170        context.verified = True
171        headers = remember(request, str(context.uid))
172        return HTTPFound(location=redirect_url, headers=headers)
173
174    # if we can not verify the user, show a page with some info about it
175    return {}
176
177
178@view_config(
179    context=User,
180    name="resend-verification-link")
181def resend_verification_link(context, request):
182    """
183    Send an email with the verification link, only if the user has not
184    been verified yet
185    """
186    # the message to be shown when the user gets back to the login page
187    query = {'message': 'already-verified'}
188    if not context.verified:
189        tokens_sent = getattr(context, 'verification_tokens_sent', 0)
190        if tokens_sent > 3:
191            # we already sent the token 3 times, we don't send it anymore
192            query = {'message': 'max-tokens-sent', 'email': context.email}
193        else:
194            if context.verification_token is None:
195                # for some reason the verification token is not there, get one
196                context.verification_token = get_verification_token()
197            send_verification_email(request, context)
198            context.verification_tokens_sent = tokens_sent + 1
199            query = {'message': 'link-sent', 'email': context.email}
200    # Send to login
201    url = request.resource_url(request.root, 'login', query=query)
202    return HTTPFound(location=url)
203
204
205@view_config(
206    context=OpenWorkouts,
207    name='forgot-password',
208    renderer='ow:templates/forgot_password.pt')
209def recover_password(context, request):  # pragma: no cover
210    # WIP
211    Form(request)
212
213
214@view_config(
215    context=User,
216    permission='view',
217    renderer='ow:templates/dashboard.pt')
218def dashboard(context, request):
219    """
220    Render a dashboard for the current user
221    """
222    # Look at the year we are viewing, if none is passed in the request,
223    # pick up the latest/newer available with activity
224    viewing_year = request.GET.get('year', None)
225    if viewing_year is None:
226        available_years = context.activity_years
227        if available_years:
228            viewing_year = available_years[0]
229    else:
230        # ensure this is an integer
231        viewing_year = int(viewing_year)
232
233    # Same for the month, if there is a year set
234    viewing_month = None
235    if viewing_year:
236        viewing_month = request.GET.get('month', None)
237        if viewing_month is None:
238            available_months = context.activity_months(viewing_year)
239            if available_months:
240                # we pick up the latest month available for the year,
241                # which means the current month in the current year
242                viewing_month = available_months[-1]
243        else:
244            # ensure this is an integer
245            viewing_month = int(viewing_month)
246
247    # pick up the workouts to be shown in the dashboard
248    workouts = context.workouts(viewing_year, viewing_month)
249
250    return {
251        'current_year': datetime.now(timezone.utc).year,
252        'current_day_name': datetime.now(timezone.utc).strftime('%a'),
253        'month_name': month_name,
254        'viewing_year': viewing_year,
255        'viewing_month': viewing_month,
256        'workouts': workouts
257    }
258
259
260@view_config(
261    context=OpenWorkouts,
262    name='profile',
263    permission='view',
264    renderer='ow:templates/profile.pt')
265@view_config(
266    context=User,
267    permission='view',
268    name='profile',
269    renderer='ow:templates/profile.pt')
270def profile(context, request):
271    """
272    "public" profile view, showing some workouts from this user, her
273    basic info, stats, etc
274    """
275    if isinstance(context, OpenWorkouts):
276        nickname = request.subpath[0]
277        user = request.root.get_user_by_nickname(nickname)
278        if user is None:
279            return HTTPNotFound()
280    else:
281        user = context
282    now = datetime.now(timezone.utc)
283    year = int(request.GET.get('year', now.year))
284    month = int(request.GET.get('month', now.month))
285    week = request.GET.get('week', None)
286    workouts = user.workouts(year, month, week)
287    totals = {
288        'distance': Decimal(0),
289        'time': timedelta(0),
290        'elevation': Decimal(0)
291    }
292
293    for workout in workouts:
294        totals['distance'] += (
295            getattr(workout, 'distance', Decimal(0)) or Decimal(0))
296        totals['time'] += (
297            getattr(workout, 'duration', timedelta(0)) or timedelta(0))
298        totals['elevation'] += (
299            getattr(workout, 'uphill', Decimal(0)) or Decimal(0))
300
301    localizer = get_localizer(request)
302    user_gender = _('Unknown')
303    for g in get_gender_names():
304        if g[0] == user.gender:
305            user_gender = localizer.translate(g[1])
306
307    # get some data to be shown in the "profile stats" totals column
308    profile_stats = {
309        'sports': user.activity_sports,
310        'years': user.activity_years,
311        'current_year': request.GET.get('stats_year', now.year),
312        'current_sport': request.GET.get('stats_sport', user.favorite_sport),
313    }
314
315    return {
316        'user': user,
317        'user_gender': user_gender,
318        'workouts': workouts,
319        'current_month': '{year}-{month}'.format(
320            year=str(year), month=str(month).zfill(2)),
321        'current_week': week,
322        'totals': totals,
323        'profile_stats': profile_stats
324    }
325
326
327@view_config(
328    context=User,
329    name='picture',
330    permission='view')
331def profile_picture(context, request):
332    if context.picture is None:
333        return HTTPNotFound()
334
335    size = request.GET.get('size', 0)
336    # we will need a tuple, it does not matter if both values are the same,
337    # Pillow will keep aspect ratio
338    size = (int(size), int(size))
339
340    image = Image.open(context.picture.open())
341
342    if size > (0, 0) and size < image.size:
343        # resize only if they are asking for smaller size, prevent
344        # someone asking for a "too big" image
345        image.thumbnail(size)
346
347    body_file = BytesIO()
348    image.save(body_file, format=image.format)
349    return Response(content_type='image', body=body_file.getvalue())
350
351
352@view_config(
353    context=User,
354    permission='edit',
355    name='edit',
356    renderer='ow:templates/edit_profile.pt')
357def edit_profile(context, request):
358    default_locale = request.registry.settings.get(
359        'pyramid.default_locale_name')
360    current_locale = request.cookies.get('_LOCALE_', default_locale)
361    # if not given a file there is an empty byte in POST, which breaks
362    # our blob storage validator.
363    # dirty fix until formencode fixes its api.is_empty method
364    if isinstance(request.POST.get('picture', None), bytes):
365        request.POST['picture'] = ''
366
367    nicknames = request.root.lowercase_nicknames
368    if context.nickname:
369        # remove the current user nickname from the list, preventing form
370        # validation error
371        nicknames.remove(context.nickname.lower())
372    state = State(emails=request.root.lowercase_emails, names=nicknames)
373    form = Form(request, schema=UserProfileSchema(), state=state, obj=context)
374
375    if 'submit' in request.POST and form.validate():
376        # No picture? do not override it
377        if not form.data['picture']:
378            del form.data['picture']
379        form.bind(context)
380        # reindex
381        request.root.reindex(context)
382        # set the cookie for the locale/lang
383        request.response.set_cookie('_LOCALE_', form.data['locale'])
384        current_locale = form.data['locale']
385        # Saved, send the user to the public view of her profile
386        return HTTPFound(location=request.resource_url(context, 'profile'),
387                         headers=request.response.headers)
388
389    # prevent crashes on the form
390    if 'picture' in form.data:
391        del form.data['picture']
392
393    localizer = get_localizer(request)
394    gender_names = [
395        (g[0], localizer.translate(g[1])) for g in get_gender_names()]
396    available_locale_names = [
397        (l[0], localizer.translate(l[1])) for l in get_available_locale_names()
398    ]
399
400    return {'form': OWFormRenderer(form),
401            'timezones': common_timezones,
402            'gender_names': gender_names,
403            'available_locale_names': available_locale_names,
404            'current_locale': current_locale}
405
406
407@view_config(
408    context=User,
409    permission='edit',
410    name='passwd',
411    renderer='ow:templates/change_password.pt')
412def change_password(context, request):
413    form = Form(request, schema=ChangePasswordSchema(),
414                state=State(user=context))
415    if 'submit' in request.POST and form.validate():
416        context.password = form.data['password']
417        return HTTPFound(location=request.resource_url(context, 'profile'))
418    return {'form': OWFormRenderer(form)}
419
420
421@view_config(
422    context=User,
423    permission='view',
424    name='week')
425def week_stats(context, request):
426    localizer = get_localizer(request)
427    stats = context.week_stats
428    json_stats = []
429    for day in stats:
430        hms = timedelta_to_hms(stats[day]['time'])
431        name = localizer.translate(weekday_name[day.weekday()])[:3]
432        day_stats = {
433            'name': name,
434            'time': str(hms[0]).zfill(2),
435            'distance': int(round(stats[day]['distance'])),
436            'elevation': int(stats[day]['elevation']),
437            'workouts': stats[day]['workouts']
438        }
439        json_stats.append(day_stats)
440    return Response(content_type='application/json',
441                    charset='utf-8',
442                    body=json.dumps(json_stats))
443
444
445@view_config(
446    context=User,
447    permission='view',
448    name='month')
449def month_stats(context, request):
450    """
451    For the given month, return a json-encoded stream containing
452    per-day workouts information.
453    """
454    localizer = get_localizer(request)
455    now = datetime.now(timezone.utc)
456    year = int(request.GET.get('year', now.year))
457    month = int(request.GET.get('month', now.month))
458    workouts = context.workouts(year, month)
459    stats = {}
460
461    for workout in workouts:
462        start = workout.start.strftime('%Y-%m-%d')
463        if start not in stats.keys():
464            stats[start] = {
465                'time': 0,  # seconds
466                'distance': 0,  # kilometers
467                'elevation': 0,  # meters
468            }
469        duration = getattr(workout, 'duration', None) or timedelta(0)
470        stats[start]['time'] += duration.seconds
471        distance = getattr(workout, 'distance', None) or 0
472        stats[start]['distance'] += int(round(distance))
473        elevation = getattr(workout, 'uphill', None) or 0
474        stats[start]['elevation'] += int(elevation)
475
476    json_stats = []
477    for day in stats.keys():
478        hms = timedelta_to_hms(timedelta(seconds=stats[day]['time']))
479        hours_label = _('hour')
480        if hms[0] > 1:
481            hours_label = _('hours')
482        time_formatted = ' '.join([
483            str(hms[0]).zfill(2), localizer.translate(hours_label),
484            str(hms[1]).zfill(2), localizer.translate(_('min.'))
485        ])
486        json_stats.append({
487            'day': day,
488            'time': stats[day]['time'],
489            'time_formatted': time_formatted,
490            'distance': stats[day]['distance'],
491            'elevation': stats[day]['elevation']
492        })
493
494    return Response(content_type='application/json',
495                    charset='utf-8',
496                    body=json.dumps(json_stats))
497
498
499@view_config(
500    context=User,
501    permission='view',
502    name='monthly')
503def last_months_stats(context, request):
504    """
505    Return a json-encoded stream with statistics for the last 12 months
506    """
507    localizer = get_localizer(request)
508    stats = context.yearly_stats
509    # this sets which month is 2 times in the stats, once this year, once
510    # the previous year. We will show it a bit different in the UI (showing
511    # the year too to prevent confusion)
512    repeated_month = datetime.now(timezone.utc).date().month
513    json_stats = []
514    for month in stats:
515        hms = timedelta_to_hms(stats[month]['time'])
516        name = localizer.translate(month_name[month[1]])[:3]
517        if month[1] == repeated_month:
518            name += ' ' + str(month[0])
519        month_stats = {
520            'id': str(month[0]) + '-' + str(month[1]).zfill(2),
521            'name': name,
522            'time': str(hms[0]).zfill(2),
523            'distance': int(round(stats[month]['distance'])),
524            'elevation': int(stats[month]['elevation']),
525            'workouts': stats[month]['workouts'],
526            'url': request.resource_url(
527                context, 'profile',
528                query={'year': str(month[0]), 'month': str(month[1])})
529        }
530        json_stats.append(month_stats)
531    return Response(content_type='application/json',
532                    charset='utf-8',
533                    body=json.dumps(json_stats))
534
535
536@view_config(
537    context=User,
538    permission='view',
539    name='weekly')
540def last_weeks_stats(context, request):
541    """
542    Return a json-encoded stream with statistics for the last 12-months, but
543    in a per-week basis
544    """
545    localizer = get_localizer(request)
546    stats = context.weekly_year_stats
547    # this sets which month is 2 times in the stats, once this year, once
548    # the previous year. We will show it a bit different in the UI (showing
549    # the year too to prevent confusion)
550    repeated_month = datetime.now(timezone.utc).date().month
551    json_stats = []
552    for week in stats:
553        hms = timedelta_to_hms(stats[week]['time'])
554        name = localizer.translate(month_name[week[1]])[:3]
555        if week[1] == repeated_month:
556            name += ' ' + str(week[0])
557        week_stats = {
558            'id': '-'.join(
559                [str(week[0]), str(week[1]).zfill(2), str(week[2])]),
560            'week': str(week[3]),  # the number of week in the current month
561            'name': name,
562            'time': str(hms[0]).zfill(2),
563            'distance': int(round(stats[week]['distance'])),
564            'elevation': int(stats[week]['elevation']),
565            'workouts': stats[week]['workouts'],
566            'url': request.resource_url(
567                context, 'profile',
568                query={'year': str(week[0]),
569                       'month': str(week[1]),
570                       'week': str(week[2])})
571        }
572        json_stats.append(week_stats)
573    return Response(content_type='application/json',
574                    charset='utf-8',
575                    body=json.dumps(json_stats))
Note: See TracBrowser for help on using the repository browser.