Rename Astakos management commands for uniformity
[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 = _('<p>Profile has been updated successfully</p>')
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     response = HttpResponse()
415     if request.user.is_authenticated():
416         email = request.user.email
417         auth_logout(request)
418         response.delete_cookie(COOKIE_NAME, path='/', domain=COOKIE_DOMAIN)
419         msg = 'Cookie deleted for %s' % email
420         logger._log(LOGGING_LEVEL, msg, [])
421     next = request.GET.get('next')
422     if next:
423         response['Location'] = next
424         response.status_code = 302
425         return response
426     elif LOGOUT_NEXT:
427         response['Location'] = LOGOUT_NEXT
428         response.status_code = 301
429         return response
430     messages.add_message(request, messages.SUCCESS, _('<p>You have successfully logged out.</p>'))
431     context = get_context(request, extra_context)
432     response.write(render_to_string(template, context_instance=context))
433     return response
434
435 @transaction.commit_manually
436 def activate(request, greeting_email_template_name='im/welcome_email.txt', helpdesk_email_template_name='im/helpdesk_notification.txt'):
437     """
438     Activates the user identified by the ``auth`` request parameter, sends a welcome email
439     and renews the user token.
440
441     The view uses commit_manually decorator in order to ensure the user state will be updated
442     only if the email will be send successfully.
443     """
444     token = request.GET.get('auth')
445     next = request.GET.get('next')
446     try:
447         user = AstakosUser.objects.get(auth_token=token)
448     except AstakosUser.DoesNotExist:
449         return HttpResponseBadRequest(_('No such user'))
450     
451     if user.is_active:
452         message = _('Account already active.')
453         messages.add_message(request, messages.ERROR, message)
454         return index(request)
455     
456     try:
457         local_user = AstakosUser.objects.get(~Q(id = user.id), email=user.email, is_active=True)
458     except AstakosUser.DoesNotExist:
459         try:
460             activate_func(user, greeting_email_template_name, helpdesk_email_template_name, verify_email=True)
461             response = prepare_response(request, user, next, renew=True)
462             transaction.commit()
463             return response
464         except SendMailError, e:
465             message = e.message
466             messages.add_message(request, messages.ERROR, message)
467             transaction.rollback()
468             return index(request)
469         except BaseException, e:
470             status = messages.ERROR
471             message = _('Something went wrong.')
472             messages.add_message(request, messages.ERROR, message)
473             logger.exception(e)
474             transaction.rollback()
475             return index(request)
476     else:
477         try:
478             user = switch_account_to_shibboleth(user, local_user, greeting_email_template_name)
479             response = prepare_response(request, user, next, renew=True)
480             transaction.commit()
481             return response
482         except SendMailError, e:
483             message = e.message
484             messages.add_message(request, messages.ERROR, message)
485             transaction.rollback()
486             return index(request)
487         except BaseException, e:
488             status = messages.ERROR
489             message = _('Something went wrong.')
490             messages.add_message(request, messages.ERROR, message)
491             logger.exception(e)
492             transaction.rollback()
493             return index(request)
494
495 def approval_terms(request, term_id=None, template_name='im/approval_terms.html', extra_context={}):
496     term = None
497     terms = None
498     if not term_id:
499         try:
500             term = ApprovalTerms.objects.order_by('-id')[0]
501         except IndexError:
502             pass
503     else:
504         try:
505              term = ApprovalTerms.objects.get(id=term_id)
506         except ApprovalTermDoesNotExist, e:
507             pass
508
509     if not term:
510         return HttpResponseRedirect(reverse('astakos.im.views.index'))
511     f = open(term.location, 'r')
512     terms = f.read()
513
514     if request.method == 'POST':
515         next = request.POST.get('next')
516         if not next:
517             next = reverse('astakos.im.views.index')
518         form = SignApprovalTermsForm(request.POST, instance=request.user)
519         if not form.is_valid():
520             return render_response(template_name,
521                            terms = terms,
522                            approval_terms_form = form,
523                            context_instance = get_context(request, extra_context))
524         user = form.save()
525         return HttpResponseRedirect(next)
526     else:
527         form = None
528         if request.user.is_authenticated() and not request.user.signed_terms():
529             form = SignApprovalTermsForm(instance=request.user)
530         return render_response(template_name,
531                                terms = terms,
532                                approval_terms_form = form,
533                                context_instance = get_context(request, extra_context))
534
535 @signed_terms_required
536 def change_password(request):
537     return password_change(request,
538                             post_change_redirect=reverse('astakos.im.views.edit_profile'),
539                             password_change_form=ExtendedPasswordChangeForm)
540
541 @transaction.commit_manually
542 def change_email(request, activation_key=None,
543                  email_template_name='registration/email_change_email.txt',
544                  form_template_name='registration/email_change_form.html',
545                  confirm_template_name='registration/email_change_done.html',
546                  extra_context={}):
547     if activation_key:
548         try:
549             user = EmailChange.objects.change_email(activation_key)
550             if request.user.is_authenticated() and request.user == user:
551                 msg = _('Email changed successfully.')
552                 messages.add_message(request, messages.SUCCESS, msg)
553                 auth_logout(request)
554                 response = prepare_response(request, user)
555                 transaction.commit()
556                 return response
557         except ValueError, e:
558             messages.add_message(request, messages.ERROR, e)
559         return render_response(confirm_template_name,
560                                modified_user = user if 'user' in locals() else None,
561                                context_instance = get_context(request,
562                                                               extra_context))
563     
564     if not request.user.is_authenticated():
565         path = quote(request.get_full_path())
566         url = request.build_absolute_uri(reverse('astakos.im.views.index'))
567         return HttpResponseRedirect(url + '?next=' + path)
568     form = EmailChangeForm(request.POST or None)
569     if request.method == 'POST' and form.is_valid():
570         try:
571             ec = form.save(email_template_name, request)
572         except SendMailError, e:
573             status = messages.ERROR
574             msg = e
575             transaction.rollback()
576         except IntegrityError, e:
577             status = messages.ERROR
578             msg = _('There is already a pending change email request.')
579         else:
580             status = messages.SUCCESS
581             msg = _('Change email request has been registered succefully.\
582                     You are going to receive a verification email in the new address.')
583             transaction.commit()
584         messages.add_message(request, status, msg)
585     return render_response(form_template_name,
586                            form = form,
587                            context_instance = get_context(request,
588                                                           extra_context))