source: OpenWorkouts-current/ow/views/user.py @ 76ebb1b

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

(#29) Add user verification by email on signup.

From now on, when a new user signs up, we set the account into an "unverified"
state. In order to complete the signup procedure, the user has to click on a
link we send by email to the email address provided on signup.

IMPORTANT: A new dependency has been added, pyramid_mailer, so remember to
install it in any existing openworkouts environment (this is done automatically
if you use the ./bin/start script):

pip install pyramid_mailer

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