source: OpenWorkouts-current/ow/views/user.py @ 24596da

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

Added missing tests, raised overall coverage

  • Property mode set to 100644
File size: 17.9 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    return {
308        'user': user,
309        'user_gender': user_gender,
310        'workouts': workouts,
311        'current_month': '{year}-{month}'.format(
312            year=str(year), month=str(month).zfill(2)),
313        'current_week': week,
314        'totals': totals
315    }
316
317
318@view_config(
319    context=User,
320    name='picture',
321    permission='view')
322def profile_picture(context, request):
323    if context.picture is None:
324        return HTTPNotFound()
325
326    size = request.GET.get('size', 0)
327    # we will need a tuple, it does not matter if both values are the same,
328    # Pillow will keep aspect ratio
329    size = (int(size), int(size))
330
331    image = Image.open(context.picture.open())
332
333    if size > (0, 0) and size < image.size:
334        # resize only if they are asking for smaller size, prevent
335        # someone asking for a "too big" image
336        image.thumbnail(size)
337
338    body_file = BytesIO()
339    image.save(body_file, format=image.format)
340    return Response(content_type='image', body=body_file.getvalue())
341
342
343@view_config(
344    context=User,
345    permission='edit',
346    name='edit',
347    renderer='ow:templates/edit_profile.pt')
348def edit_profile(context, request):
349    default_locale = request.registry.settings.get(
350        'pyramid.default_locale_name')
351    current_locale = request.cookies.get('_LOCALE_', default_locale)
352    # if not given a file there is an empty byte in POST, which breaks
353    # our blob storage validator.
354    # dirty fix until formencode fixes its api.is_empty method
355    if isinstance(request.POST.get('picture', None), bytes):
356        request.POST['picture'] = ''
357
358    nicknames = request.root.lowercase_nicknames
359    if context.nickname:
360        # remove the current user nickname from the list, preventing form
361        # validation error
362        nicknames.remove(context.nickname.lower())
363    state = State(emails=request.root.lowercase_emails, names=nicknames)
364    form = Form(request, schema=UserProfileSchema(), state=state, obj=context)
365
366    if 'submit' in request.POST and form.validate():
367        # No picture? do not override it
368        if not form.data['picture']:
369            del form.data['picture']
370        form.bind(context)
371        # reindex
372        request.root.reindex(context)
373        # set the cookie for the locale/lang
374        request.response.set_cookie('_LOCALE_', form.data['locale'])
375        current_locale = form.data['locale']
376        # Saved, send the user to the public view of her profile
377        return HTTPFound(location=request.resource_url(context, 'profile'),
378                         headers=request.response.headers)
379
380    # prevent crashes on the form
381    if 'picture' in form.data:
382        del form.data['picture']
383
384    localizer = get_localizer(request)
385    gender_names = [
386        (g[0], localizer.translate(g[1])) for g in get_gender_names()]
387    available_locale_names = [
388        (l[0], localizer.translate(l[1])) for l in get_available_locale_names()
389    ]
390
391    return {'form': OWFormRenderer(form),
392            'timezones': common_timezones,
393            'gender_names': gender_names,
394            'available_locale_names': available_locale_names,
395            'current_locale': current_locale}
396
397
398@view_config(
399    context=User,
400    permission='edit',
401    name='passwd',
402    renderer='ow:templates/change_password.pt')
403def change_password(context, request):
404    form = Form(request, schema=ChangePasswordSchema(),
405                state=State(user=context))
406    if 'submit' in request.POST and form.validate():
407        context.password = form.data['password']
408        return HTTPFound(location=request.resource_url(context, 'profile'))
409    return {'form': OWFormRenderer(form)}
410
411
412@view_config(
413    context=User,
414    permission='view',
415    name='week')
416def week_stats(context, request):
417    localizer = get_localizer(request)
418    stats = context.week_stats
419    json_stats = []
420    for day in stats:
421        hms = timedelta_to_hms(stats[day]['time'])
422        name = localizer.translate(weekday_name[day.weekday()])[:3]
423        day_stats = {
424            'name': name,
425            'time': str(hms[0]).zfill(2),
426            'distance': int(round(stats[day]['distance'])),
427            'elevation': int(stats[day]['elevation']),
428            'workouts': stats[day]['workouts']
429        }
430        json_stats.append(day_stats)
431    return Response(content_type='application/json',
432                    charset='utf-8',
433                    body=json.dumps(json_stats))
434
435
436@view_config(
437    context=User,
438    permission='view',
439    name='monthly')
440def last_months_stats(context, request):
441    """
442    Return a json-encoded stream with statistics for the last 12 months
443    """
444    localizer = get_localizer(request)
445    stats = context.yearly_stats
446    # this sets which month is 2 times in the stats, once this year, once
447    # the previous year. We will show it a bit different in the UI (showing
448    # the year too to prevent confusion)
449    repeated_month = datetime.now(timezone.utc).date().month
450    json_stats = []
451    for month in stats:
452        hms = timedelta_to_hms(stats[month]['time'])
453        name = localizer.translate(month_name[month[1]])[:3]
454        if month[1] == repeated_month:
455            name += ' ' + str(month[0])
456        month_stats = {
457            'id': str(month[0]) + '-' + str(month[1]).zfill(2),
458            'name': name,
459            'time': str(hms[0]).zfill(2),
460            'distance': int(round(stats[month]['distance'])),
461            'elevation': int(stats[month]['elevation']),
462            'workouts': stats[month]['workouts'],
463            'url': request.resource_url(
464                context, 'profile',
465                query={'year': str(month[0]), 'month': str(month[1])})
466        }
467        json_stats.append(month_stats)
468    return Response(content_type='application/json',
469                    charset='utf-8',
470                    body=json.dumps(json_stats))
471
472
473@view_config(
474    context=User,
475    permission='view',
476    name='weekly')
477def last_weeks_stats(context, request):
478    """
479    Return a json-encoded stream with statistics for the last 12-months, but
480    in a per-week basis
481    """
482    localizer = get_localizer(request)
483    stats = context.weekly_year_stats
484    # this sets which month is 2 times in the stats, once this year, once
485    # the previous year. We will show it a bit different in the UI (showing
486    # the year too to prevent confusion)
487    repeated_month = datetime.now(timezone.utc).date().month
488    json_stats = []
489    for week in stats:
490        hms = timedelta_to_hms(stats[week]['time'])
491        name = localizer.translate(month_name[week[1]])[:3]
492        if week[1] == repeated_month:
493            name += ' ' + str(week[0])
494        week_stats = {
495            'id': '-'.join(
496                [str(week[0]), str(week[1]).zfill(2), str(week[2])]),
497            'week': str(week[3]),  # the number of week in the current month
498            'name': name,
499            'time': str(hms[0]).zfill(2),
500            'distance': int(round(stats[week]['distance'])),
501            'elevation': int(stats[week]['elevation']),
502            'workouts': stats[week]['workouts'],
503            'url': request.resource_url(
504                context, 'profile',
505                query={'year': str(week[0]),
506                       'month': str(week[1]),
507                       'week': str(week[2])})
508        }
509        json_stats.append(week_stats)
510    return Response(content_type='application/json',
511                    charset='utf-8',
512                    body=json.dumps(json_stats))
Note: See TracBrowser for help on using the repository browser.