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