Flush other user sessions during password/token change
[astakos] / snf-astakos-app / astakos / im / views.py
1 # Copyright 2011-2012 GRNET S.A. All rights reserved.
2 #
3 # Redistribution and use in source and binary forms, with or
4 # without modification, are permitted provided that the following
5 # conditions are met:
6 #
7 #   1. Redistributions of source code must retain the above
8 #      copyright notice, this list of conditions and the following
9 #      disclaimer.
10 #
11 #   2. Redistributions in binary form must reproduce the above
12 #      copyright notice, this list of conditions and the following
13 #      disclaimer in the documentation and/or other materials
14 #      provided with the distribution.
15 #
16 # THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
17 # OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
19 # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
20 # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
23 # USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
24 # AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25 # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
26 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27 # POSSIBILITY OF SUCH DAMAGE.
28 #
29 # The views and conclusions contained in the software and
30 # documentation are those of the authors and should not be
31 # interpreted as representing official policies, either expressed
32 # or implied, of GRNET S.A.
33
34 import logging
35 import socket
36
37 from smtplib import SMTPException
38 from urllib import quote
39 from functools import wraps
40
41 from django.core.mail import send_mail
42 from django.http import (
43     HttpResponse, HttpResponseBadRequest, HttpResponseRedirect
44 )
45 from django.shortcuts import redirect
46 from django.template.loader import render_to_string
47 from django.utils.translation import ugettext as _
48 from django.core.urlresolvers import reverse
49 from django.contrib.auth.decorators import login_required
50 from django.contrib import messages
51 from django.db import transaction
52 from django.utils.http import urlencode
53 from django.db.utils import IntegrityError
54 from django.contrib.auth.views import password_change
55 from django.core.exceptions import ValidationError
56 from django.views.decorators.http import require_http_methods
57
58 from astakos.im.models import AstakosUser, Invitation, ApprovalTerms
59 from astakos.im.activation_backends import get_backend, SimpleBackend
60 from astakos.im.util import (
61     get_context, prepare_response, get_query, restrict_next
62 )
63 from astakos.im.forms import *
64 from astakos.im.functions import (send_greeting, send_feedback, SendMailError,
65     invite as invite_func, logout as auth_logout, activate as activate_func,
66     send_activation as send_activation_func
67 )
68 from astakos.im.settings import (
69     DEFAULT_CONTACT_EMAIL, DEFAULT_FROM_EMAIL, COOKIE_DOMAIN, IM_MODULES,
70     SITENAME, LOGOUT_NEXT, LOGGING_LEVEL
71 )
72
73 logger = logging.getLogger(__name__)
74
75 def render_response(template, tab=None, status=200, context_instance=None, **kwargs):
76     """
77     Calls ``django.template.loader.render_to_string`` with an additional ``tab``
78     keyword argument and returns an ``django.http.HttpResponse`` with the
79     specified ``status``.
80     """
81     if tab is None:
82         tab = template.partition('_')[0].partition('.html')[0]
83     kwargs.setdefault('tab', tab)
84     html = render_to_string(template, kwargs, context_instance=context_instance)
85     response = HttpResponse(html, status=status)
86     return response
87
88
89 def requires_anonymous(func):
90     """
91     Decorator checkes whether the request.user is not Anonymous and in that case
92     redirects to `logout`.
93     """
94     @wraps(func)
95     def wrapper(request, *args):
96         if not request.user.is_anonymous():
97             next = urlencode({'next': request.build_absolute_uri()})
98             logout_uri = reverse(logout) + '?' + next
99             return HttpResponseRedirect(logout_uri)
100         return func(request, *args)
101     return wrapper
102
103 def signed_terms_required(func):
104     """
105     Decorator checkes whether the request.user is Anonymous and in that case
106     redirects to `logout`.
107     """
108     @wraps(func)
109     def wrapper(request, *args, **kwargs):
110         if request.user.is_authenticated() and not request.user.signed_terms():
111             params = urlencode({'next': request.build_absolute_uri(),
112                               'show_form':''})
113             terms_uri = reverse('latest_terms') + '?' + params
114             return HttpResponseRedirect(terms_uri)
115         return func(request, *args, **kwargs)
116     return wrapper
117
118 @require_http_methods(["GET", "POST"])
119 @signed_terms_required
120 def index(request, login_template_name='im/login.html', profile_template_name='im/profile.html', extra_context=None):
121     """
122     If there is logged on user renders the profile page otherwise renders login page.
123
124     **Arguments**
125
126     ``login_template_name``
127         A custom login template to use. This is optional; if not specified,
128         this will default to ``im/login.html``.
129
130     ``profile_template_name``
131         A custom profile template to use. This is optional; if not specified,
132         this will default to ``im/profile.html``.
133
134     ``extra_context``
135         An dictionary of variables to add to the template context.
136
137     **Template:**
138
139     im/profile.html or im/login.html or ``template_name`` keyword argument.
140
141     """
142     extra_context = extra_context or {}
143     template_name = login_template_name
144     if request.user.is_authenticated():
145         return HttpResponseRedirect(reverse('astakos.im.views.edit_profile'))
146     
147     return render_response(
148         template_name,
149         login_form = LoginForm(request=request),
150         context_instance = get_context(request, extra_context)
151     )
152
153 @require_http_methods(["GET", "POST"])
154 @login_required
155 @signed_terms_required
156 @transaction.commit_manually
157 def invite(request, template_name='im/invitations.html', extra_context=None):
158     """
159     Allows a user to invite somebody else.
160
161     In case of GET request renders a form for providing the invitee information.
162     In case of POST checks whether the user has not run out of invitations and then
163     sends an invitation email to singup to the service.
164
165     The view uses commit_manually decorator in order to ensure the number of the
166     user invitations is going to be updated only if the email has been successfully sent.
167
168     If the user isn't logged in, redirects to settings.LOGIN_URL.
169
170     **Arguments**
171
172     ``template_name``
173         A custom template to use. This is optional; if not specified,
174         this will default to ``im/invitations.html``.
175
176     ``extra_context``
177         An dictionary of variables to add to the template context.
178
179     **Template:**
180
181     im/invitations.html or ``template_name`` keyword argument.
182
183     **Settings:**
184
185     The view expectes the following settings are defined:
186
187     * LOGIN_URL: login uri
188     * ASTAKOS_DEFAULT_CONTACT_EMAIL: service support email
189     * ASTAKOS_DEFAULT_FROM_EMAIL: from email
190     """
191     extra_context = extra_context or {}
192     status = None
193     message = None
194     form = InvitationForm()
195     
196     inviter = request.user
197     if request.method == 'POST':
198         form = InvitationForm(request.POST)
199         if inviter.invitations > 0:
200             if form.is_valid():
201                 try:
202                     invitation = form.save()
203                     invite_func(invitation, inviter)
204                     status = messages.SUCCESS
205                     message = _('Invitation sent to %s' % invitation.username)
206                 except SendMailError, e:
207                     status = messages.ERROR
208                     message = e.message
209                     transaction.rollback()
210                 except BaseException, e:
211                     status = messages.ERROR
212                     message = _('Something went wrong.')
213                     logger.exception(e)
214                     transaction.rollback()
215                 else:
216                     transaction.commit()
217         else:
218             status = messages.ERROR
219             message = _('No invitations left')
220     messages.add_message(request, status, message)
221
222     sent = [{'email': inv.username,
223              'realname': inv.realname,
224              'is_consumed': inv.is_consumed}
225              for inv in request.user.invitations_sent.all()]
226     kwargs = {'inviter': inviter,
227               'sent':sent}
228     context = get_context(request, extra_context, **kwargs)
229     return render_response(template_name,
230                            invitation_form = form,
231                            context_instance = context)
232
233 @require_http_methods(["GET", "POST"])
234 @login_required
235 @signed_terms_required
236 def edit_profile(request, template_name='im/profile.html', extra_context=None):
237     """
238     Allows a user to edit his/her profile.
239
240     In case of GET request renders a form for displaying the user information.
241     In case of POST updates the user informantion and redirects to ``next``
242     url parameter if exists.
243
244     If the user isn't logged in, redirects to settings.LOGIN_URL.
245
246     **Arguments**
247
248     ``template_name``
249         A custom template to use. This is optional; if not specified,
250         this will default to ``im/profile.html``.
251
252     ``extra_context``
253         An dictionary of variables to add to the template context.
254
255     **Template:**
256
257     im/profile.html or ``template_name`` keyword argument.
258
259     **Settings:**
260
261     The view expectes the following settings are defined:
262
263     * LOGIN_URL: login uri
264     """
265     extra_context = extra_context or {}
266     form = ProfileForm(
267         instance=request.user,
268         session_key=request.session.session_key
269     )
270     extra_context['next'] = request.GET.get('next')
271     if request.method == 'POST':
272         form = ProfileForm(
273             request.POST,
274             instance=request.user,
275             session_key=request.session.session_key
276         )
277         if form.is_valid():
278             try:
279                 prev_token = request.user.auth_token
280                 user = form.save()
281                 form = ProfileForm(
282                     instance=user,
283                     session_key=request.session.session_key
284                 )
285                 next = restrict_next(
286                     request.POST.get('next'),
287                     domain=COOKIE_DOMAIN
288                 )
289                 if next:
290                     return redirect(next)
291                 msg = _('<p>Profile has been updated successfully</p>')
292                 messages.add_message(request, messages.SUCCESS, msg)
293             except ValueError, ve:
294                 messages.add_message(request, messages.ERROR, ve)
295     elif request.method == "GET":
296         request.user.is_verified = True
297         request.user.save()
298     return render_response(template_name,
299                            profile_form = form,
300                            context_instance = get_context(request,
301                                                           extra_context))
302
303 @require_http_methods(["GET", "POST"])
304 def signup(request, template_name='im/signup.html', on_success='im/signup_complete.html', extra_context=None, backend=None):
305     """
306     Allows a user to create a local account.
307
308     In case of GET request renders a form for entering the user information.
309     In case of POST handles the signup.
310
311     The user activation will be delegated to the backend specified by the ``backend`` keyword argument
312     if present, otherwise to the ``astakos.im.activation_backends.InvitationBackend``
313     if settings.ASTAKOS_INVITATIONS_ENABLED is True or ``astakos.im.activation_backends.SimpleBackend`` if not
314     (see activation_backends);
315     
316     Upon successful user creation, if ``next`` url parameter is present the user is redirected there
317     otherwise renders the same page with a success message.
318     
319     On unsuccessful creation, renders ``template_name`` with an error message.
320     
321     **Arguments**
322     
323     ``template_name``
324         A custom template to render. This is optional;
325         if not specified, this will default to ``im/signup.html``.
326
327     ``on_success``
328         A custom template to render in case of success. This is optional;
329         if not specified, this will default to ``im/signup_complete.html``.
330
331     ``extra_context``
332         An dictionary of variables to add to the template context.
333
334     **Template:**
335     
336     im/signup.html or ``template_name`` keyword argument.
337     im/signup_complete.html or ``on_success`` keyword argument. 
338     """
339     extra_context = extra_context or {}
340     if request.user.is_authenticated():
341         return HttpResponseRedirect(reverse('astakos.im.views.edit_profile'))
342     
343     provider = get_query(request).get('provider', 'local')
344     id = get_query(request).get('id')
345     try:
346         instance = AstakosUser.objects.get(id=id) if id else None
347     except AstakosUser.DoesNotExist:
348         instance = None
349
350     try:
351         if not backend:
352             backend = get_backend(request)
353         form = backend.get_signup_form(provider, instance)
354     except Exception, e:
355         form = SimpleBackend(request).get_signup_form(provider)
356         messages.add_message(request, messages.ERROR, e)
357     if request.method == 'POST':
358         if form.is_valid():
359             user = form.save(commit=False)
360             try:
361                 result = backend.handle_activation(user)
362                 status = messages.SUCCESS
363                 message = result.message
364                 user.save()
365                 if 'additional_email' in form.cleaned_data:
366                     additional_email = form.cleaned_data['additional_email']
367                     if additional_email != user.email:
368                         user.additionalmail_set.create(email=additional_email)
369                         msg = 'Additional email: %s saved for user %s.' % (
370                             additional_email,
371                             user.email
372                         )
373                         logger._log(LOGGING_LEVEL, msg, [])
374                 if user and user.is_active:
375                     next = request.POST.get('next', '')
376                     return prepare_response(request, user, next=next)
377                 messages.add_message(request, status, message)
378                 return render_response(
379                     on_success,
380                     context_instance=get_context(
381                         request,
382                         extra_context
383                     )
384                 )
385             except SendMailError, e:
386                 logger.exception(e)
387                 status = messages.ERROR
388                 message = e.message
389                 messages.add_message(request, status, message)
390             except BaseException, e:
391                 logger.exception(e)
392                 status = messages.ERROR
393                 message = _('Something went wrong.')
394                 messages.add_message(request, status, message)
395                 logger.exception(e)
396     return render_response(template_name,
397                            signup_form = form,
398                            provider = provider,
399                            context_instance=get_context(request, extra_context))
400
401 @require_http_methods(["GET", "POST"])
402 @login_required
403 @signed_terms_required
404 def feedback(request, template_name='im/feedback.html', email_template_name='im/feedback_mail.txt', extra_context=None):
405     """
406     Allows a user to send feedback.
407
408     In case of GET request renders a form for providing the feedback information.
409     In case of POST sends an email to support team.
410
411     If the user isn't logged in, redirects to settings.LOGIN_URL.
412
413     **Arguments**
414
415     ``template_name``
416         A custom template to use. This is optional; if not specified,
417         this will default to ``im/feedback.html``.
418
419     ``extra_context``
420         An dictionary of variables to add to the template context.
421
422     **Template:**
423
424     im/signup.html or ``template_name`` keyword argument.
425
426     **Settings:**
427
428     * LOGIN_URL: login uri
429     * ASTAKOS_DEFAULT_CONTACT_EMAIL: List of feedback recipients
430     """
431     extra_context = extra_context or {}
432     if request.method == 'GET':
433         form = FeedbackForm()
434     if request.method == 'POST':
435         if not request.user:
436             return HttpResponse('Unauthorized', status=401)
437
438         form = FeedbackForm(request.POST)
439         if form.is_valid():
440             msg = form.cleaned_data['feedback_msg']
441             data = form.cleaned_data['feedback_data']
442             try:
443                 send_feedback(msg, data, request.user, email_template_name)
444             except SendMailError, e:
445                 message = e.message
446                 status = messages.ERROR
447             else:
448                 message = _('Feedback successfully sent')
449                 status = messages.SUCCESS
450             messages.add_message(request, status, message)
451     return render_response(template_name,
452                            feedback_form = form,
453                            context_instance = get_context(request, extra_context))
454
455 @require_http_methods(["GET"])
456 def logout(request, template='registration/logged_out.html', extra_context=None):
457     """
458     Wraps `django.contrib.auth.logout`.
459     """
460     extra_context = extra_context or {}
461     response = HttpResponse()
462     if request.user.is_authenticated():
463         email = request.user.email
464         auth_logout(request)
465     next = restrict_next(
466         request.GET.get('next'),
467         domain=COOKIE_DOMAIN
468     )
469     if next:
470         response['Location'] = next
471         response.status_code = 302
472     elif LOGOUT_NEXT:
473         response['Location'] = LOGOUT_NEXT
474         response.status_code = 301
475     else:
476         messages.add_message(request, messages.SUCCESS, _('<p>You have successfully logged out.</p>'))
477         context = get_context(request, extra_context)
478         response.write(render_to_string(template, context_instance=context))
479     return response
480
481 @require_http_methods(["GET", "POST"])
482 @transaction.commit_manually
483 def activate(request, greeting_email_template_name='im/welcome_email.txt', helpdesk_email_template_name='im/helpdesk_notification.txt'):
484     """
485     Activates the user identified by the ``auth`` request parameter, sends a welcome email
486     and renews the user token.
487
488     The view uses commit_manually decorator in order to ensure the user state will be updated
489     only if the email will be send successfully.
490     """
491     token = request.GET.get('auth')
492     next = request.GET.get('next')
493     try:
494         user = AstakosUser.objects.get(auth_token=token)
495     except AstakosUser.DoesNotExist:
496         return HttpResponseBadRequest(_('No such user'))
497     
498     if user.is_active:
499         message = _('Account already active.')
500         messages.add_message(request, messages.ERROR, message)
501         return index(request)
502     
503     try:
504         activate_func(user, greeting_email_template_name, helpdesk_email_template_name, verify_email=True)
505         response = prepare_response(request, user, next, renew=True)
506         transaction.commit()
507         return response
508     except SendMailError, e:
509         message = e.message
510         messages.add_message(request, messages.ERROR, message)
511         transaction.rollback()
512         return index(request)
513     except BaseException, e:
514         status = messages.ERROR
515         message = _('Something went wrong.')
516         messages.add_message(request, messages.ERROR, message)
517         logger.exception(e)
518         transaction.rollback()
519         return index(request)
520
521 @require_http_methods(["GET", "POST"])
522 def approval_terms(request, term_id=None, template_name='im/approval_terms.html', extra_context=None):
523     extra_context = extra_context or {}
524     term = None
525     terms = None
526     if not term_id:
527         try:
528             term = ApprovalTerms.objects.order_by('-id')[0]
529         except IndexError:
530             pass
531     else:
532         try:
533              term = ApprovalTerms.objects.get(id=term_id)
534         except ApprovalTermDoesNotExist, e:
535             pass
536
537     if not term:
538         return HttpResponseRedirect(reverse('astakos.im.views.index'))
539     f = open(term.location, 'r')
540     terms = f.read()
541
542     if request.method == 'POST':
543         next = restrict_next(
544             request.POST.get('next'),
545             domain=COOKIE_DOMAIN
546         )
547         if not next:
548             next = reverse('astakos.im.views.index')
549         form = SignApprovalTermsForm(request.POST, instance=request.user)
550         if not form.is_valid():
551             return render_response(template_name,
552                            terms = terms,
553                            approval_terms_form = form,
554                            context_instance = get_context(request, extra_context))
555         user = form.save()
556         return HttpResponseRedirect(next)
557     else:
558         form = None
559         if request.user.is_authenticated() and not request.user.signed_terms():
560             form = SignApprovalTermsForm(instance=request.user)
561         return render_response(template_name,
562                                terms = terms,
563                                approval_terms_form = form,
564                                context_instance = get_context(request, extra_context))
565
566 @require_http_methods(["GET", "POST"])
567 @login_required
568 @signed_terms_required
569 @transaction.commit_manually
570 def change_email(request, activation_key=None,
571                  email_template_name='registration/email_change_email.txt',
572                  form_template_name='registration/email_change_form.html',
573                  confirm_template_name='registration/email_change_done.html',
574                  extra_context=None):
575     extra_context = extra_context or {}
576     if activation_key:
577         try:
578             user = EmailChange.objects.change_email(activation_key)
579             if request.user.is_authenticated() and request.user == user:
580                 msg = _('Email changed successfully.')
581                 messages.add_message(request, messages.SUCCESS, msg)
582                 auth_logout(request)
583                 response = prepare_response(request, user)
584                 transaction.commit()
585                 return response
586         except ValueError, e:
587             messages.add_message(request, messages.ERROR, e)
588         return render_response(confirm_template_name,
589                                modified_user = user if 'user' in locals() else None,
590                                context_instance = get_context(request,
591                                                               extra_context))
592     
593     if not request.user.is_authenticated():
594         path = quote(request.get_full_path())
595         url = request.build_absolute_uri(reverse('astakos.im.views.index'))
596         return HttpResponseRedirect(url + '?next=' + path)
597     form = EmailChangeForm(request.POST or None)
598     if request.method == 'POST' and form.is_valid():
599         try:
600             ec = form.save(email_template_name, request)
601         except SendMailError, e:
602             status = messages.ERROR
603             msg = e
604             transaction.rollback()
605         except IntegrityError, e:
606             status = messages.ERROR
607             msg = _('There is already a pending change email request.')
608         else:
609             status = messages.SUCCESS
610             msg = _('Change email request has been registered succefully.\
611                     You are going to receive a verification email in the new address.')
612             transaction.commit()
613         messages.add_message(request, status, msg)
614     return render_response(form_template_name,
615                            form = form,)
616
617
618 def send_activation(request, user_id, template_name='im/login.html', extra_context=None):
619     extra_context = extra_context or {}
620     try:
621         u = AstakosUser.objects.get(id=user_id)
622     except AstakosUser.DoesNotExist:
623         messages.error(request, _('Invalid user id'))
624     else:
625         try:
626             send_activation_func(u)
627             msg = _('Activation sent.')
628             messages.success(request, msg)
629         except SendMailError, e:
630             messages.error(request, e)
631     return render_response(
632         template_name,
633         login_form = LoginForm(request=request), 
634         context_instance = get_context(
635             request,
636             extra_context
637         )
638     )
639