source: OpenWorkouts-current/ow/views/user.py @ 8bae554

currentfeature/docs
Last change on this file since 8bae554 was 8bae554, checked in by Borja Lopez <borja@…>, 5 years ago

(#67) Allow users to send again the verification link (up to 3 times)
to the email address they provided when signing up.

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