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

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

(#69) Added translations for User gender.
(+ added a third gender option, "robot" ;-D)

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