source: OpenWorkouts-current/ow/views/user.py @ 3ac70ca

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

(#7) Improved tooltip that shows per-day workout stats when hovering a day
on the calendar heatmap chart in the user profile page.

  • Property mode set to 100644
File size: 20.7 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                'workouts': 0,
469                'sports': {},
470            }
471        duration = getattr(workout, 'duration', None) or timedelta(0)
472        stats[start]['time'] += duration.seconds
473        distance = getattr(workout, 'distance', None) or 0
474        stats[start]['distance'] += int(round(distance))
475        elevation = getattr(workout, 'uphill', None) or 0
476        stats[start]['elevation'] += int(elevation)
477        stats[start]['workouts'] += 1
478        if workout.sport not in stats[start]['sports'].keys():
479            stats[start]['sports'][workout.sport] = 0
480        stats[start]['sports'][workout.sport] += 1
481
482    json_stats = []
483    for day in stats.keys():
484        hms = timedelta_to_hms(timedelta(seconds=stats[day]['time']))
485        hours_label = _('hour')
486        if hms[0] > 1:
487            hours_label = _('hours')
488        time_formatted = ' '.join([
489            str(hms[0]).zfill(2), localizer.translate(hours_label),
490            str(hms[1]).zfill(2), localizer.translate(_('min.'))
491        ])
492        distance_formatted = str(round(stats[day]['distance'])) + ' km'
493        elevation_formatted = str(round(stats[day]['elevation'])) + ' m'
494        json_stats.append({
495            'day': day,
496            'time': stats[day]['time'],
497            'time_formatted': time_formatted,
498            'distance': stats[day]['distance'],
499            'distance_formatted': distance_formatted,
500            'elevation': stats[day]['elevation'],
501            'elevation_formatted': elevation_formatted,
502            'workouts': stats[day]['workouts'],
503            'sports': stats[day]['sports']
504        })
505
506    return Response(content_type='application/json',
507                    charset='utf-8',
508                    body=json.dumps(json_stats))
509
510
511@view_config(
512    context=User,
513    permission='view',
514    name='monthly')
515def last_months_stats(context, request):
516    """
517    Return a json-encoded stream with statistics for the last 12 months
518    """
519    localizer = get_localizer(request)
520    stats = context.yearly_stats
521    # this sets which month is 2 times in the stats, once this year, once
522    # the previous year. We will show it a bit different in the UI (showing
523    # the year too to prevent confusion)
524    repeated_month = datetime.now(timezone.utc).date().month
525    json_stats = []
526    for month in stats:
527        hms = timedelta_to_hms(stats[month]['time'])
528        name = localizer.translate(month_name[month[1]])[:3]
529        if month[1] == repeated_month:
530            name += ' ' + str(month[0])
531        month_stats = {
532            'id': str(month[0]) + '-' + str(month[1]).zfill(2),
533            'name': name,
534            'time': str(hms[0]).zfill(2),
535            'distance': int(round(stats[month]['distance'])),
536            'elevation': int(stats[month]['elevation']),
537            'workouts': stats[month]['workouts'],
538            'url': request.resource_url(
539                context, 'profile',
540                query={'year': str(month[0]), 'month': str(month[1])})
541        }
542        json_stats.append(month_stats)
543    return Response(content_type='application/json',
544                    charset='utf-8',
545                    body=json.dumps(json_stats))
546
547
548@view_config(
549    context=User,
550    permission='view',
551    name='weekly')
552def last_weeks_stats(context, request):
553    """
554    Return a json-encoded stream with statistics for the last 12-months, but
555    in a per-week basis
556    """
557    localizer = get_localizer(request)
558    stats = context.weekly_year_stats
559    # this sets which month is 2 times in the stats, once this year, once
560    # the previous year. We will show it a bit different in the UI (showing
561    # the year too to prevent confusion)
562    repeated_month = datetime.now(timezone.utc).date().month
563    json_stats = []
564    for week in stats:
565        hms = timedelta_to_hms(stats[week]['time'])
566        name = localizer.translate(month_name[week[1]])[:3]
567        if week[1] == repeated_month:
568            name += ' ' + str(week[0])
569        week_stats = {
570            'id': '-'.join(
571                [str(week[0]), str(week[1]).zfill(2), str(week[2])]),
572            'week': str(week[3]),  # the number of week in the current month
573            'name': name,
574            'time': str(hms[0]).zfill(2),
575            'distance': int(round(stats[week]['distance'])),
576            'elevation': int(stats[week]['elevation']),
577            'workouts': stats[week]['workouts'],
578            'url': request.resource_url(
579                context, 'profile',
580                query={'year': str(week[0]),
581                       'month': str(week[1]),
582                       'week': str(week[2])})
583        }
584        json_stats.append(week_stats)
585    return Response(content_type='application/json',
586                    charset='utf-8',
587                    body=json.dumps(json_stats))
Note: See TracBrowser for help on using the repository browser.