00cf8b52d8d7e86b999ed3ee0f74ea5417d4e0d4
[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.contrib.auth import logout as auth_logout
51 from django.utils.http import urlencode
52 from django.http import HttpResponseRedirect, HttpResponseBadRequest
53 from django.db.utils import IntegrityError
54 from django.contrib.auth.views import password_change
55 from django.core.exceptions import ValidationError
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 from astakos.im.settings import DEFAULT_CONTACT_EMAIL, DEFAULT_FROM_EMAIL, COOKIE_NAME, COOKIE_DOMAIN, IM_MODULES, SITENAME, LOGOUT_NEXT
63 from astakos.im.functions import invite as invite_func
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     return render_response(template_name,
270                            reset_cookie = reset_cookie,
271                            profile_form = form,
272                            context_instance = get_context(request,
273                                                           extra_context))
274
275 def signup(request, template_name='im/signup.html', on_success='im/signup_complete.html', extra_context={}, backend=None):
276     """
277     Allows a user to create a local account.
278
279     In case of GET request renders a form for providing the user information.
280     In case of POST handles the signup.
281
282     The user activation will be delegated to the backend specified by the ``backend`` keyword argument
283     if present, otherwise to the ``astakos.im.activation_backends.InvitationBackend``
284     if settings.ASTAKOS_INVITATIONS_ENABLED is True or ``astakos.im.activation_backends.SimpleBackend`` if not
285     (see activation_backends);
286     
287     Upon successful user creation if ``next`` url parameter is present the user is redirected there
288     otherwise renders the same page with a success message.
289     
290     On unsuccessful creation, renders ``template_name`` with an error message.
291     
292     **Arguments**
293     
294     ``template_name``
295         A custom template to render. This is optional;
296         if not specified, this will default to ``im/signup.html``.
297
298
299     ``on_success``
300         A custom template to render in case of success. This is optional;
301         if not specified, this will default to ``im/signup_complete.html``.
302
303     ``extra_context``
304         An dictionary of variables to add to the template context.
305
306     **Template:**
307     
308     im/signup.html or ``template_name`` keyword argument.
309     im/signup_complete.html or ``on_success`` keyword argument. 
310     """
311     if request.user.is_authenticated():
312         return HttpResponseRedirect(reverse('astakos.im.views.index'))
313     
314     provider = get_query(request).get('provider', 'local')
315     try:
316         if not backend:
317             backend = get_backend(request)
318         form = backend.get_signup_form(provider)
319     except Exception, e:
320         form = SimpleBackend(request).get_signup_form(provider)
321         messages.add_message(request, messages.ERROR, e)
322     if request.method == 'POST':
323         if form.is_valid():
324             user = form.save(commit=False)
325             try:
326                 result = backend.handle_activation(user)
327                 status = messages.SUCCESS
328                 message = result.message
329                 user.save()
330                 if user and user.is_active:
331                     next = request.POST.get('next', '')
332                     return prepare_response(request, user, next=next)
333                 messages.add_message(request, status, message)
334                 return render_response(on_success,
335                                        context_instance=get_context(request, extra_context))
336             except SendMailError, e:
337                 status = messages.ERROR
338                 message = e.message
339                 messages.add_message(request, status, message)
340             except BaseException, e:
341                 status = messages.ERROR
342                 message = _('Something went wrong.')
343                 messages.add_message(request, status, message)
344                 logger.exception(e)
345     return render_response(template_name,
346                            signup_form = form,
347                            provider = provider,
348                            context_instance=get_context(request, extra_context))
349
350 @login_required
351 @signed_terms_required
352 def feedback(request, template_name='im/feedback.html', email_template_name='im/feedback_mail.txt', extra_context={}):
353     """
354     Allows a user to send feedback.
355
356     In case of GET request renders a form for providing the feedback information.
357     In case of POST sends an email to support team.
358
359     If the user isn't logged in, redirects to settings.LOGIN_URL.
360
361     **Arguments**
362
363     ``template_name``
364         A custom template to use. This is optional; if not specified,
365         this will default to ``im/feedback.html``.
366
367     ``extra_context``
368         An dictionary of variables to add to the template context.
369
370     **Template:**
371
372     im/signup.html or ``template_name`` keyword argument.
373
374     **Settings:**
375
376     * LOGIN_URL: login uri
377     * ASTAKOS_DEFAULT_CONTACT_EMAIL: List of feedback recipients
378     """
379     if request.method == 'GET':
380         form = FeedbackForm()
381     if request.method == 'POST':
382         if not request.user:
383             return HttpResponse('Unauthorized', status=401)
384
385         form = FeedbackForm(request.POST)
386         if form.is_valid():
387             msg = form.cleaned_data['feedback_msg'],
388             data = form.cleaned_data['feedback_data']
389             try:
390                 send_feedback(msg, data, request.user, email_template_name)
391             except SendMailError, e:
392                 message = e.message
393                 status = messages.ERROR
394             else:
395                 message = _('Feedback successfully sent')
396                 status = messages.SUCCESS
397             messages.add_message(request, status, message)
398     return render_response(template_name,
399                            feedback_form = form,
400                            context_instance = get_context(request, extra_context))
401
402 def logout(request, template='registration/logged_out.html', extra_context={}):
403     """
404     Wraps `django.contrib.auth.logout` and delete the cookie.
405     """
406     auth_logout(request)
407     response = HttpResponse()
408     response.delete_cookie(COOKIE_NAME, path='/', domain=COOKIE_DOMAIN)
409     next = request.GET.get('next')
410     if next:
411         response['Location'] = next
412         response.status_code = 302
413         return response
414     elif LOGOUT_NEXT:
415         response['Location'] = LOGOUT_NEXT
416         response.status_code = 301
417         return response
418     messages.add_message(request, messages.SUCCESS, _('You have successfully logged out.'))
419     context = get_context(request, extra_context)
420     response.write(render_to_string(template, context_instance=context))
421     return response
422
423 @transaction.commit_manually
424 def activate(request, email_template_name='im/welcome_email.txt', on_failure=''):
425     """
426     Activates the user identified by the ``auth`` request parameter, sends a welcome email
427     and renews the user token.
428
429     The view uses commit_manually decorator in order to ensure the user state will be updated
430     only if the email will be send successfully.
431     """
432     token = request.GET.get('auth')
433     next = request.GET.get('next')
434     try:
435         user = AstakosUser.objects.get(auth_token=token)
436     except AstakosUser.DoesNotExist:
437         return HttpResponseBadRequest(_('No such user'))
438     
439     try:
440         local_user = AstakosUser.objects.get(email=user.email, is_active=True)
441     except AstakosUser.DoesNotExist:
442         user.is_active = True
443         user.email_verified = True
444         try:
445             user.save()
446         except ValidationError, e:
447             return HttpResponseBadRequest(e)
448         
449     else:
450         # switch the local account to shibboleth one
451         local_user.provider = 'shibboleth'
452         local_user.set_unusable_password()
453         local_user.third_party_identifier = user.third_party_identifier
454         try:
455             local_user.save()
456         except ValidationError, e:
457             return HttpResponseBadRequest(e)
458         user.delete()
459         user = local_user
460     
461     try:
462         send_greeting(user, email_template_name)
463         response = prepare_response(request, user, next, renew=True)
464         transaction.commit()
465         return response
466     except SendMailError, e:
467         message = e.message
468         messages.add_message(request, messages.ERROR, message)
469         transaction.rollback()
470         return signup(request, on_failure='im/signup.html')
471     except BaseException, e:
472         status = messages.ERROR
473         message = _('Something went wrong.')
474         logger.exception(e)
475         transaction.rollback()
476         return signup(request, on_failure='im/signup.html')
477
478 def approval_terms(request, term_id=None, template_name='im/approval_terms.html', extra_context={}):
479     term = None
480     terms = None
481     if not term_id:
482         try:
483             term = ApprovalTerms.objects.order_by('-id')[0]
484         except IndexError:
485             pass
486     else:
487         try:
488              term = ApprovalTerms.objects.get(id=term_id)
489         except ApprovalTermDoesNotExist, e:
490             pass
491
492     if not term:
493         return HttpResponseRedirect(reverse('astakos.im.views.index'))
494     f = open(term.location, 'r')
495     terms = f.read()
496
497     if request.method == 'POST':
498         next = request.POST.get('next')
499         if not next:
500             next = reverse('astakos.im.views.index')
501         form = SignApprovalTermsForm(request.POST, instance=request.user)
502         if not form.is_valid():
503             return render_response(template_name,
504                            terms = terms,
505                            approval_terms_form = form,
506                            context_instance = get_context(request, extra_context))
507         user = form.save()
508         return HttpResponseRedirect(next)
509     else:
510         form = None
511         if request.user.is_authenticated() and not request.user.signed_terms():
512             form = SignApprovalTermsForm(instance=request.user)
513         return render_response(template_name,
514                                terms = terms,
515                                approval_terms_form = form,
516                                context_instance = get_context(request, extra_context))
517
518 @signed_terms_required
519 def change_password(request):
520     return password_change(request, post_change_redirect=reverse('astakos.im.views.edit_profile'))