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