rewrite ``activate`` view
[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.db.models import Q
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 get_context, prepare_response, set_cookie, get_query
60 from astakos.im.forms import *
61 from astakos.im.functions import send_greeting, send_feedback, SendMailError, \
62     invite as invite_func, logout as auth_logout, activate as activate_func, switch_account_to_shibboleth
63 from astakos.im.settings import DEFAULT_CONTACT_EMAIL, DEFAULT_FROM_EMAIL, COOKIE_NAME, COOKIE_DOMAIN, IM_MODULES, SITENAME, LOGOUT_NEXT, LOGGING_LEVEL
64
65 logger = logging.getLogger(__name__)
66
67 def render_response(template, tab=None, status=200, reset_cookie=False, context_instance=None, **kwargs):
68     """
69     Calls ``django.template.loader.render_to_string`` with an additional ``tab``
70     keyword argument and returns an ``django.http.HttpResponse`` with the
71     specified ``status``.
72     """
73     if tab is None:
74         tab = template.partition('_')[0].partition('.html')[0]
75     kwargs.setdefault('tab', tab)
76     html = render_to_string(template, kwargs, context_instance=context_instance)
77     response = HttpResponse(html, status=status)
78     if reset_cookie:
79         set_cookie(response, context_instance['request'].user)
80     return response
81
82
83 def requires_anonymous(func):
84     """
85     Decorator checkes whether the request.user is not Anonymous and in that case
86     redirects to `logout`.
87     """
88     @wraps(func)
89     def wrapper(request, *args):
90         if not request.user.is_anonymous():
91             next = urlencode({'next': request.build_absolute_uri()})
92             logout_uri = reverse(logout) + '?' + next
93             return HttpResponseRedirect(logout_uri)
94         return func(request, *args)
95     return wrapper
96
97 def signed_terms_required(func):
98     """
99     Decorator checkes whether the request.user is Anonymous and in that case
100     redirects to `logout`.
101     """
102     @wraps(func)
103     def wrapper(request, *args, **kwargs):
104         if request.user.is_authenticated() and not request.user.signed_terms():
105             params = urlencode({'next': request.build_absolute_uri(),
106                               'show_form':''})
107             terms_uri = reverse('latest_terms') + '?' + params
108             return HttpResponseRedirect(terms_uri)
109         return func(request, *args, **kwargs)
110     return wrapper
111
112 @signed_terms_required
113 def index(request, login_template_name='im/login.html', profile_template_name='im/profile.html', extra_context={}):
114     """
115     If there is logged on user renders the profile page otherwise renders login page.
116
117     **Arguments**
118
119     ``login_template_name``
120         A custom login template to use. This is optional; if not specified,
121         this will default to ``im/login.html``.
122
123     ``profile_template_name``
124         A custom profile template to use. This is optional; if not specified,
125         this will default to ``im/profile.html``.
126
127     ``extra_context``
128         An dictionary of variables to add to the template context.
129
130     **Template:**
131
132     im/profile.html or im/login.html or ``template_name`` keyword argument.
133
134     """
135     template_name = login_template_name
136     if request.user.is_authenticated():
137         return HttpResponseRedirect(reverse('astakos.im.views.edit_profile'))
138     return render_response(template_name,
139                            login_form = LoginForm(request=request),
140                            context_instance = get_context(request, extra_context))
141
142 @login_required
143 @signed_terms_required
144 @transaction.commit_manually
145 def invite(request, template_name='im/invitations.html', extra_context={}):
146     """
147     Allows a user to invite somebody else.
148
149     In case of GET request renders a form for providing the invitee information.
150     In case of POST checks whether the user has not run out of invitations and then
151     sends an invitation email to singup to the service.
152
153     The view uses commit_manually decorator in order to ensure the number of the
154     user invitations is going to be updated only if the email has been successfully sent.
155
156     If the user isn't logged in, redirects to settings.LOGIN_URL.
157
158     **Arguments**
159
160     ``template_name``
161         A custom template to use. This is optional; if not specified,
162         this will default to ``im/invitations.html``.
163
164     ``extra_context``
165         An dictionary of variables to add to the template context.
166
167     **Template:**
168
169     im/invitations.html or ``template_name`` keyword argument.
170
171     **Settings:**
172
173     The view expectes the following settings are defined:
174
175     * LOGIN_URL: login uri
176     * ASTAKOS_DEFAULT_CONTACT_EMAIL: service support email
177     * ASTAKOS_DEFAULT_FROM_EMAIL: from email
178     """
179     status = None
180     message = None
181     form = InvitationForm()
182     
183     inviter = request.user
184     if request.method == 'POST':
185         form = InvitationForm(request.POST)
186         if inviter.invitations > 0:
187             if form.is_valid():
188                 try:
189                     invitation = form.save()
190                     invite_func(invitation, inviter)
191                     status = messages.SUCCESS
192                     message = _('Invitation sent to %s' % invitation.username)
193                 except SendMailError, e:
194                     status = messages.ERROR
195                     message = e.message
196                     transaction.rollback()
197                 except BaseException, e:
198                     status = messages.ERROR
199                     message = _('Something went wrong.')
200                     logger.exception(e)
201                     transaction.rollback()
202                 else:
203                     transaction.commit()
204         else:
205             status = messages.ERROR
206             message = _('No invitations left')
207     messages.add_message(request, status, message)
208
209     sent = [{'email': inv.username,
210              'realname': inv.realname,
211              'is_consumed': inv.is_consumed}
212              for inv in request.user.invitations_sent.all()]
213     kwargs = {'inviter': inviter,
214               'sent':sent}
215     context = get_context(request, extra_context, **kwargs)
216     return render_response(template_name,
217                            invitation_form = form,
218                            context_instance = context)
219
220 @login_required
221 @signed_terms_required
222 def edit_profile(request, template_name='im/profile.html', extra_context={}):
223     """
224     Allows a user to edit his/her profile.
225
226     In case of GET request renders a form for displaying the user information.
227     In case of POST updates the user informantion and redirects to ``next``
228     url parameter if exists.
229
230     If the user isn't logged in, redirects to settings.LOGIN_URL.
231
232     **Arguments**
233
234     ``template_name``
235         A custom template to use. This is optional; if not specified,
236         this will default to ``im/profile.html``.
237
238     ``extra_context``
239         An dictionary of variables to add to the template context.
240
241     **Template:**
242
243     im/profile.html or ``template_name`` keyword argument.
244
245     **Settings:**
246
247     The view expectes the following settings are defined:
248
249     * LOGIN_URL: login uri
250     """
251     form = ProfileForm(instance=request.user)
252     extra_context['next'] = request.GET.get('next')
253     reset_cookie = False
254     if request.method == 'POST':
255         form = ProfileForm(request.POST, instance=request.user)
256         if form.is_valid():
257             try:
258                 prev_token = request.user.auth_token
259                 user = form.save()
260                 reset_cookie = user.auth_token != prev_token
261                 form = ProfileForm(instance=user)
262                 next = request.POST.get('next')
263                 if next:
264                     return redirect(next)
265                 msg = _('Profile has been updated successfully')
266                 messages.add_message(request, messages.SUCCESS, msg)
267             except ValueError, ve:
268                 messages.add_message(request, messages.ERROR, ve)
269     elif request.method == "GET":
270         request.user.is_verified = True
271         request.user.save()
272     return render_response(template_name,
273                            reset_cookie = reset_cookie,
274                            profile_form = form,
275                            context_instance = get_context(request,
276                                                           extra_context))
277
278 def signup(request, template_name='im/signup.html', on_success='im/signup_complete.html', extra_context={}, backend=None):
279     """
280     Allows a user to create a local account.
281
282     In case of GET request renders a form for entering the user information.
283     In case of POST handles the signup.
284
285     The user activation will be delegated to the backend specified by the ``backend`` keyword argument
286     if present, otherwise to the ``astakos.im.activation_backends.InvitationBackend``
287     if settings.ASTAKOS_INVITATIONS_ENABLED is True or ``astakos.im.activation_backends.SimpleBackend`` if not
288     (see activation_backends);
289     
290     Upon successful user creation, if ``next`` url parameter is present the user is redirected there
291     otherwise renders the same page with a success message.
292     
293     On unsuccessful creation, renders ``template_name`` with an error message.
294     
295     **Arguments**
296     
297     ``template_name``
298         A custom template to render. This is optional;
299         if not specified, this will default to ``im/signup.html``.
300
301     ``on_success``
302         A custom template to render in case of success. This is optional;
303         if not specified, this will default to ``im/signup_complete.html``.
304
305     ``extra_context``
306         An dictionary of variables to add to the template context.
307
308     **Template:**
309     
310     im/signup.html or ``template_name`` keyword argument.
311     im/signup_complete.html or ``on_success`` keyword argument. 
312     """
313     if request.user.is_authenticated():
314         return HttpResponseRedirect(reverse('astakos.im.views.edit_profile'))
315     
316     provider = get_query(request).get('provider', 'local')
317     try:
318         if not backend:
319             backend = get_backend(request)
320         form = backend.get_signup_form(provider)
321     except Exception, e:
322         form = SimpleBackend(request).get_signup_form(provider)
323         messages.add_message(request, messages.ERROR, e)
324     if request.method == 'POST':
325         if form.is_valid():
326             user = form.save(commit=False)
327             try:
328                 result = backend.handle_activation(user)
329                 status = messages.SUCCESS
330                 message = result.message
331                 user.save()
332                 if 'additional_email' in form.cleaned_data:
333                     additional_email = form.cleaned_data['additional_email']
334                     if additional_email != user.email:
335                         user.additionalmail_set.create(email=additional_email)
336                         msg = 'Additional email: %s saved for user %s.' % (additional_email, user.email)
337                         logger._log(LOGGING_LEVEL, msg, [])
338                 if user and user.is_active:
339                     next = request.POST.get('next', '')
340                     return prepare_response(request, user, next=next)
341                 messages.add_message(request, status, message)
342                 return render_response(on_success,
343                                        context_instance=get_context(request, extra_context))
344             except SendMailError, e:
345                 status = messages.ERROR
346                 message = e.message
347                 messages.add_message(request, status, message)
348             except BaseException, e:
349                 status = messages.ERROR
350                 message = _('Something went wrong.')
351                 messages.add_message(request, status, message)
352                 logger.exception(e)
353     return render_response(template_name,
354                            signup_form = form,
355                            provider = provider,
356                            context_instance=get_context(request, extra_context))
357
358 @login_required
359 @signed_terms_required
360 def feedback(request, template_name='im/feedback.html', email_template_name='im/feedback_mail.txt', extra_context={}):
361     """
362     Allows a user to send feedback.
363
364     In case of GET request renders a form for providing the feedback information.
365     In case of POST sends an email to support team.
366
367     If the user isn't logged in, redirects to settings.LOGIN_URL.
368
369     **Arguments**
370
371     ``template_name``
372         A custom template to use. This is optional; if not specified,
373         this will default to ``im/feedback.html``.
374
375     ``extra_context``
376         An dictionary of variables to add to the template context.
377
378     **Template:**
379
380     im/signup.html or ``template_name`` keyword argument.
381
382     **Settings:**
383
384     * LOGIN_URL: login uri
385     * ASTAKOS_DEFAULT_CONTACT_EMAIL: List of feedback recipients
386     """
387     if request.method == 'GET':
388         form = FeedbackForm()
389     if request.method == 'POST':
390         if not request.user:
391             return HttpResponse('Unauthorized', status=401)
392
393         form = FeedbackForm(request.POST)
394         if form.is_valid():
395             msg = form.cleaned_data['feedback_msg']
396             data = form.cleaned_data['feedback_data']
397             try:
398                 send_feedback(msg, data, request.user, email_template_name)
399             except SendMailError, e:
400                 message = e.message
401                 status = messages.ERROR
402             else:
403                 message = _('Feedback successfully sent')
404                 status = messages.SUCCESS
405             messages.add_message(request, status, message)
406     return render_response(template_name,
407                            feedback_form = form,
408                            context_instance = get_context(request, extra_context))
409
410 def logout(request, template='registration/logged_out.html', extra_context={}):
411     """
412     Wraps `django.contrib.auth.logout` and delete the cookie.
413     """
414     msg = 'Cookie deleted for %s' % (request.user.email)
415     auth_logout(request)
416     response = HttpResponse()
417     response.delete_cookie(COOKIE_NAME, path='/', domain=COOKIE_DOMAIN)
418     logger._log(LOGGING_LEVEL, msg, [])
419     next = request.GET.get('next')
420     if next:
421         response['Location'] = next
422         response.status_code = 302
423         return response
424     elif LOGOUT_NEXT:
425         response['Location'] = LOGOUT_NEXT
426         response.status_code = 301
427         return response
428     messages.add_message(request, messages.SUCCESS, _('You have successfully logged out.'))
429     context = get_context(request, extra_context)
430     response.write(render_to_string(template, context_instance=context))
431     return response
432
433 @transaction.commit_manually
434 def activate(request, email_template_name='im/welcome_email.txt', helpdesk_email_template_name='im/helpdesk_notification.txt'):
435     """
436     Activates the user identified by the ``auth`` request parameter, sends a welcome email
437     and renews the user token.
438
439     The view uses commit_manually decorator in order to ensure the user state will be updated
440     only if the email will be send successfully.
441     """
442     token = request.GET.get('auth')
443     next = request.GET.get('next')
444     try:
445         user = AstakosUser.objects.get(auth_token=token)
446     except AstakosUser.DoesNotExist:
447         return HttpResponseBadRequest(_('No such user'))
448     
449     if user.is_active:
450         message = _('Account already active.')
451         messages.add_message(request, messages.ERROR, message)
452         return index(request)
453     
454     try:
455         local_user = AstakosUser.objects.get(~Q(id = user.id), email=user.email, is_active=True)
456     except AstakosUser.DoesNotExist:
457         try:
458             activate_func(user, email_template_name, helpdesk_email_template_name, verify_email=True)
459             response = prepare_response(request, user, next, renew=True)
460             transaction.commit()
461             return response
462         except SendMailError, e:
463             message = e.message
464             messages.add_message(request, messages.ERROR, message)
465             transaction.rollback()
466             return index(request)
467         except BaseException, e:
468             status = messages.ERROR
469             message = _('Something went wrong.')
470             messages.add_message(request, messages.ERROR, message)
471             logger.exception(e)
472             transaction.rollback()
473             return index(request)
474     else:
475         try:
476             user = switch_account_to_shibboleth(user, local_user)
477             send_greeting(user, email_template_name)
478             response = prepare_response(request, user, next, renew=True)
479             transaction.commit()
480             return response
481         except SendMailError, e:
482             message = e.message
483             messages.add_message(request, messages.ERROR, message)
484             transaction.rollback()
485             return index(request)
486         except BaseException, e:
487             status = messages.ERROR
488             message = _('Something went wrong.')
489             messages.add_message(request, messages.ERROR, message)
490             logger.exception(e)
491             transaction.rollback()
492             return index(request)
493
494 def approval_terms(request, term_id=None, template_name='im/approval_terms.html', extra_context={}):
495     term = None
496     terms = None
497     if not term_id:
498         try:
499             term = ApprovalTerms.objects.order_by('-id')[0]
500         except IndexError:
501             pass
502     else:
503         try:
504              term = ApprovalTerms.objects.get(id=term_id)
505         except ApprovalTermDoesNotExist, e:
506             pass
507
508     if not term:
509         return HttpResponseRedirect(reverse('astakos.im.views.index'))
510     f = open(term.location, 'r')
511     terms = f.read()
512
513     if request.method == 'POST':
514         next = request.POST.get('next')
515         if not next:
516             next = reverse('astakos.im.views.index')
517         form = SignApprovalTermsForm(request.POST, instance=request.user)
518         if not form.is_valid():
519             return render_response(template_name,
520                            terms = terms,
521                            approval_terms_form = form,
522                            context_instance = get_context(request, extra_context))
523         user = form.save()
524         return HttpResponseRedirect(next)
525     else:
526         form = None
527         if request.user.is_authenticated() and not request.user.signed_terms():
528             form = SignApprovalTermsForm(instance=request.user)
529         return render_response(template_name,
530                                terms = terms,
531                                approval_terms_form = form,
532                                context_instance = get_context(request, extra_context))
533
534 @signed_terms_required
535 def change_password(request):
536     return password_change(request, post_change_redirect=reverse('astakos.im.views.edit_profile'))
537
538 @transaction.commit_manually
539 def change_email(request, activation_key=None,
540                  email_template_name='registration/email_change_email.txt',
541                  form_template_name='registration/email_change_form.html',
542                  confirm_template_name='registration/email_change_done.html',
543                  extra_context={}):
544     if activation_key:
545         try:
546             user = EmailChange.objects.change_email(activation_key)
547             if request.user.is_authenticated() and request.user == user:
548                 msg = _('Email changed successfully.')
549                 messages.add_message(request, messages.SUCCESS, msg)
550                 auth_logout(request)
551                 response = prepare_response(request, user)
552                 transaction.commit()
553                 return response
554         except ValueError, e:
555             messages.add_message(request, messages.ERROR, e)
556         return render_response(confirm_template_name,
557                                modified_user = user if 'user' in locals() else None,
558                                context_instance = get_context(request,
559                                                               extra_context))
560     
561     if not request.user.is_authenticated():
562         path = quote(request.get_full_path())
563         url = request.build_absolute_uri(reverse('astakos.im.views.index'))
564         return HttpResponseRedirect(url + '?next=' + path)
565     form = EmailChangeForm(request.POST or None)
566     if request.method == 'POST' and form.is_valid():
567         try:
568             ec = form.save(email_template_name, request)
569         except SendMailError, e:
570             status = messages.ERROR
571             msg = e
572             transaction.rollback()
573         except IntegrityError, e:
574             status = messages.ERROR
575             msg = _('There is already a pending change email request.')
576         else:
577             status = messages.SUCCESS
578             msg = _('Change email request has been registered succefully.\
579                     You are going to receive a verification email in the new address.')
580             transaction.commit()
581         messages.add_message(request, status, msg)
582     return render_response(form_template_name,
583                            form = form,
584                            context_instance = get_context(request,
585                                                           extra_context))