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

current
Last change on this file since 4678c5e was 4678c5e, checked in by Borja Lopez <borja@…>, 5 years ago

(#71) Keep the "distance/time/elevation" filter when clicking on bars
in the profile yearly stats chart

  • Property mode set to 100644
File size: 20.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    # filter_by can be 'distance', 'time' or 'elevation', defaults to
287    # 'distance' if no value is provided
288    filter_by = request.GET.get('filter_by', 'distance')
289    workouts = user.workouts(year, month, week)
290    totals = {
291        'distance': Decimal(0),
292        'time': timedelta(0),
293        'elevation': Decimal(0)
294    }
295
296    for workout in workouts:
297        totals['distance'] += (
298            getattr(workout, 'distance', Decimal(0)) or Decimal(0))
299        totals['time'] += (
300            getattr(workout, 'duration', timedelta(0)) or timedelta(0))
301        totals['elevation'] += (
302            getattr(workout, 'uphill', Decimal(0)) or Decimal(0))
303
304    localizer = get_localizer(request)
305    user_gender = _('Unknown')
306    for g in get_gender_names():
307        if g[0] == user.gender:
308            user_gender = localizer.translate(g[1])
309
310    # get some data to be shown in the "profile stats" totals column
311    profile_stats = {
312        'sports': user.activity_sports,
313        'years': user.activity_years,
314        'current_year': request.GET.get('stats_year', now.year),
315        'current_sport': request.GET.get('stats_sport', user.favorite_sport),
316    }
317
318    return {
319        'user': user,
320        'user_gender': user_gender,
321        'workouts': workouts,
322        'current_month': '{year}-{month}'.format(
323            year=str(year), month=str(month).zfill(2)),
324        'current_week': week,
325        'totals': totals,
326        'profile_stats': profile_stats,
327        'filter_by': filter_by
328    }
329
330
331@view_config(
332    context=User,
333    name='picture',
334    permission='view')
335def profile_picture(context, request):
336    if context.picture is None:
337        return HTTPNotFound()
338
339    size = request.GET.get('size', 0)
340    # we will need a tuple, it does not matter if both values are the same,
341    # Pillow will keep aspect ratio
342    size = (int(size), int(size))
343
344    image = Image.open(context.picture.open())
345
346    if size > (0, 0) and size < image.size:
347        # resize only if they are asking for smaller size, prevent
348        # someone asking for a "too big" image
349        image.thumbnail(size)
350
351    body_file = BytesIO()
352    image.save(body_file, format=image.format)
353    return Response(content_type='image', body=body_file.getvalue())
354
355
356@view_config(
357    context=User,
358    permission='edit',
359    name='edit',
360    renderer='ow:templates/edit_profile.pt')
361def edit_profile(context, request):
362    default_locale = request.registry.settings.get(
363        'pyramid.default_locale_name')
364    current_locale = request.cookies.get('_LOCALE_', default_locale)
365    # if not given a file there is an empty byte in POST, which breaks
366    # our blob storage validator.
367    # dirty fix until formencode fixes its api.is_empty method
368    if isinstance(request.POST.get('picture', None), bytes):
369        request.POST['picture'] = ''
370
371    nicknames = request.root.lowercase_nicknames
372    if context.nickname:
373        # remove the current user nickname from the list, preventing form
374        # validation error
375        nicknames.remove(context.nickname.lower())
376    state = State(emails=request.root.lowercase_emails, names=nicknames)
377    form = Form(request, schema=UserProfileSchema(), state=state, obj=context)
378
379    if 'submit' in request.POST and form.validate():
380        # No picture? do not override it
381        if not form.data['picture']:
382            del form.data['picture']
383        form.bind(context)
384        # reindex
385        request.root.reindex(context)
386        # set the cookie for the locale/lang
387        request.response.set_cookie('_LOCALE_', form.data['locale'])
388        current_locale = form.data['locale']
389        # Saved, send the user to the public view of her profile
390        return HTTPFound(location=request.resource_url(context, 'profile'),
391                         headers=request.response.headers)
392
393    # prevent crashes on the form
394    if 'picture' in form.data:
395        del form.data['picture']
396
397    localizer = get_localizer(request)
398    gender_names = [
399        (g[0], localizer.translate(g[1])) for g in get_gender_names()]
400    available_locale_names = [
401        (l[0], localizer.translate(l[1])) for l in get_available_locale_names()
402    ]
403
404    return {'form': OWFormRenderer(form),
405            'timezones': common_timezones,
406            'gender_names': gender_names,
407            'available_locale_names': available_locale_names,
408            'current_locale': current_locale}
409
410
411@view_config(
412    context=User,
413    permission='edit',
414    name='passwd',
415    renderer='ow:templates/change_password.pt')
416def change_password(context, request):
417    form = Form(request, schema=ChangePasswordSchema(),
418                state=State(user=context))
419    if 'submit' in request.POST and form.validate():
420        context.password = form.data['password']
421        return HTTPFound(location=request.resource_url(context, 'profile'))
422    return {'form': OWFormRenderer(form)}
423
424
425@view_config(
426    context=User,
427    permission='view',
428    name='week')
429def week_stats(context, request):
430    localizer = get_localizer(request)
431    stats = context.week_stats
432    json_stats = []
433    for day in stats:
434        hms = timedelta_to_hms(stats[day]['time'])
435        name = localizer.translate(weekday_name[day.weekday()])[:3]
436        day_stats = {
437            'name': name,
438            'time': str(hms[0]).zfill(2),
439            'distance': int(round(stats[day]['distance'])),
440            'elevation': int(stats[day]['elevation']),
441            'workouts': stats[day]['workouts']
442        }
443        json_stats.append(day_stats)
444    return Response(content_type='application/json',
445                    charset='utf-8',
446                    body=json.dumps(json_stats))
447
448
449@view_config(
450    context=User,
451    permission='view',
452    name='month')
453def month_stats(context, request):
454    """
455    For the given month, return a json-encoded stream containing
456    per-day workouts information.
457    """
458    localizer = get_localizer(request)
459    now = datetime.now(timezone.utc)
460    year = int(request.GET.get('year', now.year))
461    month = int(request.GET.get('month', now.month))
462    workouts = context.workouts(year, month)
463    stats = {}
464
465    for workout in workouts:
466        start = workout.start.strftime('%Y-%m-%d')
467        if start not in stats.keys():
468            stats[start] = {
469                'time': 0,  # seconds
470                'distance': 0,  # kilometers
471                'elevation': 0,  # meters
472                'workouts': 0,
473                'sports': {},
474            }
475        duration = getattr(workout, 'duration', None) or timedelta(0)
476        stats[start]['time'] += duration.seconds
477        distance = getattr(workout, 'distance', None) or 0
478        stats[start]['distance'] += int(round(distance))
479        elevation = getattr(workout, 'uphill', None) or 0
480        stats[start]['elevation'] += int(elevation)
481        stats[start]['workouts'] += 1
482        if workout.sport not in stats[start]['sports'].keys():
483            stats[start]['sports'][workout.sport] = 0
484        stats[start]['sports'][workout.sport] += 1
485
486    json_stats = []
487    for day in stats.keys():
488        hms = timedelta_to_hms(timedelta(seconds=stats[day]['time']))
489        hours_label = _('hour')
490        if hms[0] > 1:
491            hours_label = _('hours')
492        time_formatted = ' '.join([
493            str(hms[0]).zfill(2), localizer.translate(hours_label),
494            str(hms[1]).zfill(2), localizer.translate(_('min.'))
495        ])
496        distance_formatted = str(round(stats[day]['distance'])) + ' km'
497        elevation_formatted = str(round(stats[day]['elevation'])) + ' m'
498        json_stats.append({
499            'day': day,
500            'time': stats[day]['time'],
501            'time_formatted': time_formatted,
502            'distance': stats[day]['distance'],
503            'distance_formatted': distance_formatted,
504            'elevation': stats[day]['elevation'],
505            'elevation_formatted': elevation_formatted,
506            'workouts': stats[day]['workouts'],
507            'sports': stats[day]['sports']
508        })
509
510    return Response(content_type='application/json',
511                    charset='utf-8',
512                    body=json.dumps(json_stats))
513
514
515@view_config(
516    context=User,
517    permission='view',
518    name='monthly')
519def last_months_stats(context, request):
520    """
521    Return a json-encoded stream with statistics for the last 12 months
522    """
523    localizer = get_localizer(request)
524    stats = context.yearly_stats
525    # this sets which month is 2 times in the stats, once this year, once
526    # the previous year. We will show it a bit different in the UI (showing
527    # the year too to prevent confusion)
528    repeated_month = datetime.now(timezone.utc).date().month
529    json_stats = []
530    for month in stats:
531        hms = timedelta_to_hms(stats[month]['time'])
532        name = localizer.translate(month_name[month[1]])[:3]
533        if month[1] == repeated_month:
534            name += ' ' + str(month[0])
535        month_stats = {
536            'id': str(month[0]) + '-' + str(month[1]).zfill(2),
537            'name': name,
538            'time': str(hms[0]).zfill(2),
539            'distance': int(round(stats[month]['distance'])),
540            'elevation': int(stats[month]['elevation']),
541            'workouts': stats[month]['workouts'],
542            'url': request.resource_url(
543                context, 'profile',
544                query={'year': str(month[0]), 'month': str(month[1])})
545        }
546        json_stats.append(month_stats)
547    return Response(content_type='application/json',
548                    charset='utf-8',
549                    body=json.dumps(json_stats))
550
551
552@view_config(
553    context=User,
554    permission='view',
555    name='weekly')
556def last_weeks_stats(context, request):
557    """
558    Return a json-encoded stream with statistics for the last 12-months, but
559    in a per-week basis
560    """
561    localizer = get_localizer(request)
562    stats = context.weekly_year_stats
563    # this sets which month is 2 times in the stats, once this year, once
564    # the previous year. We will show it a bit different in the UI (showing
565    # the year too to prevent confusion)
566    repeated_month = datetime.now(timezone.utc).date().month
567    json_stats = []
568    for week in stats:
569        hms = timedelta_to_hms(stats[week]['time'])
570        name = localizer.translate(month_name[week[1]])[:3]
571        if week[1] == repeated_month:
572            name += ' ' + str(week[0])
573        week_stats = {
574            'id': '-'.join(
575                [str(week[0]), str(week[1]).zfill(2), str(week[2])]),
576            'week': str(week[3]),  # the number of week in the current month
577            'name': name,
578            'time': str(hms[0]).zfill(2),
579            'distance': int(round(stats[week]['distance'])),
580            'elevation': int(stats[week]['elevation']),
581            'workouts': stats[week]['workouts'],
582            'url': request.resource_url(
583                context, 'profile',
584                query={'year': str(week[0]),
585                       'month': str(week[1]),
586                       'week': str(week[2])})
587        }
588        json_stats.append(week_stats)
589    return Response(content_type='application/json',
590                    charset='utf-8',
591                    body=json.dumps(json_stats))
Note: See TracBrowser for help on using the repository browser.