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

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

(#69) Ensure month and weekday names are translated.

This is a dirty hack, which basically uses static strings marked for
translation within openworkouts, instead of relying on the python
locale and calendar translations.

We need this hack, as in some operating systems (OpenBSD), using
locale.setlocale() does not make any difference when using calendar.month_name
(names appear always in the system locale/LANG).

  • 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] == context.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.