Special handling for login failure messages
[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 HttpResponse, HttpResponseBadRequest
43 from django.shortcuts import redirect
44 from django.template.loader import render_to_string
45 from django.utils.translation import ugettext as _
46 from django.core.urlresolvers import reverse
47 from django.contrib.auth.decorators import login_required
48 from django.contrib import messages
49 from django.db import transaction
50 from django.utils.http import urlencode
51 from django.http import HttpResponseRedirect, HttpResponseBadRequest
52 from django.db.utils import IntegrityError
53 from django.contrib.auth.views import password_change
54 from django.core.exceptions import ValidationError
55 from django.views.decorators.http import require_http_methods
56
57 from astakos.im.models import AstakosUser, Invitation, ApprovalTerms
58 from astakos.im.activation_backends import get_backend, SimpleBackend
59 from astakos.im.util import (
60     get_context, prepare_response, set_cookie, get_query, restrict_next
61 )
62 from astakos.im.forms import *
63 from astakos.im.functions import (send_greeting, send_feedback, SendMailError,
64     invite as invite_func, logout as auth_logout, activate as activate_func,
65     send_activation as send_activation_func
66 )
67 from astakos.im.settings import (DEFAULT_CONTACT_EMAIL, DEFAULT_FROM_EMAIL,
68     COOKIE_NAME, COOKIE_DOMAIN, IM_MODULES, SITENAME, LOGOUT_NEXT, LOGGING_LEVEL
69 )
70
71 logger = logging.getLogger(__name__)
72
73 def render_response(template, tab=None, status=200, reset_cookie=False, context_instance=None, **kwargs):
74     """
75     Calls ``django.template.loader.render_to_string`` with an additional ``tab``
76     keyword argument and returns an ``django.http.HttpResponse`` with the
77     specified ``status``.
78     """
79     if tab is None:
80         tab = template.partition('_')[0].partition('.html')[0]
81     kwargs.setdefault('tab', tab)
82     html = render_to_string(template, kwargs, context_instance=context_instance)
83     response = HttpResponse(html, status=status)
84     if reset_cookie:
85         set_cookie(response, context_instance['request'].user)
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(instance=request.user)
267     extra_context['next'] = request.GET.get('next')
268     reset_cookie = False
269     if request.method == 'POST':
270         form = ProfileForm(request.POST, instance=request.user)
271         if form.is_valid():
272             try:
273                 prev_token = request.user.auth_token
274                 user = form.save()
275                 reset_cookie = user.auth_token != prev_token
276                 form = ProfileForm(instance=user)
277                 next = restrict_next(
278                     request.POST.get('next'),
279                     domain=COOKIE_DOMAIN
280                 )
281                 if next:
282                     return redirect(next)
283                 msg = _('<p>Profile has been updated successfully</p>')
284                 messages.add_message(request, messages.SUCCESS, msg)
285             except ValueError, ve:
286                 messages.add_message(request, messages.ERROR, ve)
287     elif request.method == "GET":
288         request.user.is_verified = True
289         request.user.save()
290     return render_response(template_name,
291                            reset_cookie = reset_cookie,
292                            profile_form = form,
293                            context_instance = get_context(request,
294                                                           extra_context))
295
296 @require_http_methods(["GET", "POST"])
297 def signup(request, template_name='im/signup.html', on_success='im/signup_complete.html', extra_context=None, backend=None):
298     """
299     Allows a user to create a local account.
300
301     In case of GET request renders a form for entering the user information.
302     In case of POST handles the signup.
303
304     The user activation will be delegated to the backend specified by the ``backend`` keyword argument
305     if present, otherwise to the ``astakos.im.activation_backends.InvitationBackend``
306     if settings.ASTAKOS_INVITATIONS_ENABLED is True or ``astakos.im.activation_backends.SimpleBackend`` if not
307     (see activation_backends);
308     
309     Upon successful user creation, if ``next`` url parameter is present the user is redirected there
310     otherwise renders the same page with a success message.
311     
312     On unsuccessful creation, renders ``template_name`` with an error message.
313     
314     **Arguments**
315     
316     ``template_name``
317         A custom template to render. This is optional;
318         if not specified, this will default to ``im/signup.html``.
319
320     ``on_success``
321         A custom template to render in case of success. This is optional;
322         if not specified, this will default to ``im/signup_complete.html``.
323
324     ``extra_context``
325         An dictionary of variables to add to the template context.
326
327     **Template:**
328     
329     im/signup.html or ``template_name`` keyword argument.
330     im/signup_complete.html or ``on_success`` keyword argument. 
331     """
332     extra_context = extra_context or {}
333     if request.user.is_authenticated():
334         return HttpResponseRedirect(reverse('astakos.im.views.edit_profile'))
335     
336     provider = get_query(request).get('provider', 'local')
337     try:
338         if not backend:
339             backend = get_backend(request)
340         form = backend.get_signup_form(provider)
341     except Exception, e:
342         form = SimpleBackend(request).get_signup_form(provider)
343         messages.add_message(request, messages.ERROR, e)
344     if request.method == 'POST':
345         if form.is_valid():
346             user = form.save(commit=False)
347             try:
348                 result = backend.handle_activation(user)
349                 status = messages.SUCCESS
350                 message = result.message
351                 user.save()
352                 if 'additional_email' in form.cleaned_data:
353                     additional_email = form.cleaned_data['additional_email']
354                     if additional_email != user.email:
355                         user.additionalmail_set.create(email=additional_email)
356                         msg = 'Additional email: %s saved for user %s.' % (additional_email, user.email)
357                         logger._log(LOGGING_LEVEL, msg, [])
358                 if user and user.is_active:
359                     next = request.POST.get('next', '')
360                     return prepare_response(request, user, next=next)
361                 messages.add_message(request, status, message)
362                 return render_response(on_success,
363                                        context_instance=get_context(request, extra_context))
364             except SendMailError, e:
365                 logger.exception(e)
366                 status = messages.ERROR
367                 message = e.message
368                 messages.add_message(request, status, message)
369             except BaseException, e:
370                 logger.exception(e)
371                 status = messages.ERROR
372                 message = _('Something went wrong.')
373                 messages.add_message(request, status, message)
374                 logger.exception(e)
375     return render_response(template_name,
376                            signup_form = form,
377                            provider = provider,
378                            context_instance=get_context(request, extra_context))
379
380 @require_http_methods(["GET", "POST"])
381 @login_required
382 @signed_terms_required
383 def feedback(request, template_name='im/feedback.html', email_template_name='im/feedback_mail.txt', extra_context=None):
384     """
385     Allows a user to send feedback.
386
387     In case of GET request renders a form for providing the feedback information.
388     In case of POST sends an email to support team.
389
390     If the user isn't logged in, redirects to settings.LOGIN_URL.
391
392     **Arguments**
393
394     ``template_name``
395         A custom template to use. This is optional; if not specified,
396         this will default to ``im/feedback.html``.
397
398     ``extra_context``
399         An dictionary of variables to add to the template context.
400
401     **Template:**
402
403     im/signup.html or ``template_name`` keyword argument.
404
405     **Settings:**
406
407     * LOGIN_URL: login uri
408     * ASTAKOS_DEFAULT_CONTACT_EMAIL: List of feedback recipients
409     """
410     extra_context = extra_context or {}
411     if request.method == 'GET':
412         form = FeedbackForm()
413     if request.method == 'POST':
414         if not request.user:
415             return HttpResponse('Unauthorized', status=401)
416
417         form = FeedbackForm(request.POST)
418         if form.is_valid():
419             msg = form.cleaned_data['feedback_msg']
420             data = form.cleaned_data['feedback_data']
421             try:
422                 send_feedback(msg, data, request.user, email_template_name)
423             except SendMailError, e:
424                 message = e.message
425                 status = messages.ERROR
426             else:
427                 message = _('Feedback successfully sent')
428                 status = messages.SUCCESS
429             messages.add_message(request, status, message)
430     return render_response(template_name,
431                            feedback_form = form,
432                            context_instance = get_context(request, extra_context))
433
434 @require_http_methods(["GET"])
435 def logout(request, template='registration/logged_out.html', extra_context=None):
436     """
437     Wraps `django.contrib.auth.logout` and delete the cookie.
438     """
439     extra_context = extra_context or {}
440     response = HttpResponse()
441     msg = None
442     if request.user.is_authenticated():
443         email = request.user.email
444         auth_logout(request)
445         response.delete_cookie(COOKIE_NAME, path='/', domain=COOKIE_DOMAIN)
446         msg = 'Cookie deleted for %s' % email
447     next = restrict_next(
448         request.GET.get('next'),
449         domain=COOKIE_DOMAIN
450     )
451     if next:
452         response['Location'] = next
453         response.status_code = 302
454         if msg:
455             logger._log(LOGGING_LEVEL, msg, [])
456         return response
457     elif LOGOUT_NEXT:
458         response['Location'] = LOGOUT_NEXT
459         response.status_code = 301
460         if msg:
461             logger._log(LOGGING_LEVEL, msg, [])
462         return response
463     messages.add_message(request, messages.SUCCESS, _('<p>You have successfully logged out.</p>'))
464     context = get_context(request, extra_context)
465     response.write(render_to_string(template, context_instance=context))
466     if msg:
467         logger._log(LOGGING_LEVEL, msg, [])
468     return response
469
470 @require_http_methods(["GET", "POST"])
471 @transaction.commit_manually
472 def activate(request, greeting_email_template_name='im/welcome_email.txt', helpdesk_email_template_name='im/helpdesk_notification.txt'):
473     """
474     Activates the user identified by the ``auth`` request parameter, sends a welcome email
475     and renews the user token.
476
477     The view uses commit_manually decorator in order to ensure the user state will be updated
478     only if the email will be send successfully.
479     """
480     token = request.GET.get('auth')
481     next = request.GET.get('next')
482     try:
483         user = AstakosUser.objects.get(auth_token=token)
484     except AstakosUser.DoesNotExist:
485         return HttpResponseBadRequest(_('No such user'))
486     
487     if user.is_active:
488         message = _('Account already active.')
489         messages.add_message(request, messages.ERROR, message)
490         return index(request)
491     
492     try:
493         activate_func(user, greeting_email_template_name, helpdesk_email_template_name, verify_email=True)
494         response = prepare_response(request, user, next, renew=True)
495         transaction.commit()
496         return response
497     except SendMailError, e:
498         message = e.message
499         messages.add_message(request, messages.ERROR, message)
500         transaction.rollback()
501         return index(request)
502     except BaseException, e:
503         status = messages.ERROR
504         message = _('Something went wrong.')
505         messages.add_message(request, messages.ERROR, message)
506         logger.exception(e)
507         transaction.rollback()
508         return index(request)
509
510 @require_http_methods(["GET", "POST"])
511 def approval_terms(request, term_id=None, template_name='im/approval_terms.html', extra_context=None):
512     extra_context = extra_context or {}
513     term = None
514     terms = None
515     if not term_id:
516         try:
517             term = ApprovalTerms.objects.order_by('-id')[0]
518         except IndexError:
519             pass
520     else:
521         try:
522              term = ApprovalTerms.objects.get(id=term_id)
523         except ApprovalTermDoesNotExist, e:
524             pass
525
526     if not term:
527         return HttpResponseRedirect(reverse('astakos.im.views.index'))
528     f = open(term.location, 'r')
529     terms = f.read()
530
531     if request.method == 'POST':
532         next = restrict_next(
533             request.POST.get('next'),
534             domain=COOKIE_DOMAIN
535         )
536         if not next:
537             next = reverse('astakos.im.views.index')
538         form = SignApprovalTermsForm(request.POST, instance=request.user)
539         if not form.is_valid():
540             return render_response(template_name,
541                            terms = terms,
542                            approval_terms_form = form,
543                            context_instance = get_context(request, extra_context))
544         user = form.save()
545         return HttpResponseRedirect(next)
546     else:
547         form = None
548         if request.user.is_authenticated() and not request.user.signed_terms():
549             form = SignApprovalTermsForm(instance=request.user)
550         return render_response(template_name,
551                                terms = terms,
552                                approval_terms_form = form,
553                                context_instance = get_context(request, extra_context))
554
555 @require_http_methods(["GET", "POST"])
556 @signed_terms_required
557 def change_password(request):
558     return password_change(request,
559                             post_change_redirect=reverse('astakos.im.views.edit_profile'),
560                             password_change_form=ExtendedPasswordChangeForm)
561
562 @require_http_methods(["GET", "POST"])
563 @login_required
564 @signed_terms_required
565 @transaction.commit_manually
566 def change_email(request, activation_key=None,
567                  email_template_name='registration/email_change_email.txt',
568                  form_template_name='registration/email_change_form.html',
569                  confirm_template_name='registration/email_change_done.html',
570                  extra_context=None):
571     extra_context = extra_context or {}
572     if activation_key:
573         try:
574             user = EmailChange.objects.change_email(activation_key)
575             if request.user.is_authenticated() and request.user == user:
576                 msg = _('Email changed successfully.')
577                 messages.add_message(request, messages.SUCCESS, msg)
578                 auth_logout(request)
579                 response = prepare_response(request, user)
580                 transaction.commit()
581                 return response
582         except ValueError, e:
583             messages.add_message(request, messages.ERROR, e)
584         return render_response(confirm_template_name,
585                                modified_user = user if 'user' in locals() else None,
586                                context_instance = get_context(request,
587                                                               extra_context))
588     
589     if not request.user.is_authenticated():
590         path = quote(request.get_full_path())
591         url = request.build_absolute_uri(reverse('astakos.im.views.index'))
592         return HttpResponseRedirect(url + '?next=' + path)
593     form = EmailChangeForm(request.POST or None)
594     if request.method == 'POST' and form.is_valid():
595         try:
596             ec = form.save(email_template_name, request)
597         except SendMailError, e:
598             status = messages.ERROR
599             msg = e
600             transaction.rollback()
601         except IntegrityError, e:
602             status = messages.ERROR
603             msg = _('There is already a pending change email request.')
604         else:
605             status = messages.SUCCESS
606             msg = _('Change email request has been registered succefully.\
607                     You are going to receive a verification email in the new address.')
608             transaction.commit()
609         messages.add_message(request, status, msg)
610     return render_response(form_template_name,
611                            form = form,)
612
613
614 def send_activation(request, user_id, template_name='im/login.html', extra_context=None):
615     extra_context = extra_context or {}
616     try:
617         u = AstakosUser.objects.get(id=user_id)
618     except AstakosUser.DoesNotExist:
619         messages.error(request, _('Invalid user id'))
620     else:
621         try:
622             send_activation_func(u)
623             msg = _('Activation sent.')
624             messages.success(request, msg)
625         except SendMailError, e:
626             messages.error(request, e)
627     return render_response(
628         template_name,
629         login_form = LoginForm(request=request), 
630         context_instance = get_context(
631             request,
632             extra_context
633         )
634     )
635