support shibboleth with invitations & enable modifyuser command to remove a user...
[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, SimpleBackend
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, 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     form = InvitationForm()
181     
182     inviter = request.user
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                     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     query_dict = request.__getattribute__(request.method)
315     provider = query_dict.get('provider', 'local')
316     try:
317         if not backend:
318             backend = get_backend(request)
319         form = backend.get_signup_form(provider)
320     except (Invitation.DoesNotExist, ValueError), e:
321         form = SimpleBackend(request).get_signup_form(provider)
322         messages.add_message(request, messages.ERROR, e)
323     if request.method == 'POST':
324         if form.is_valid():
325             user = form.save(commit=False)
326             try:
327                 result = backend.handle_activation(user)
328                 status = messages.SUCCESS
329                 message = result.message
330                 user.save()
331                 if user and user.is_active:
332                     next = request.POST.get('next', '')
333                     return prepare_response(request, user, next=next)
334                 messages.add_message(request, status, message)
335                 return render_response(on_success,
336                                        context_instance=get_context(request, extra_context))
337             except SendMailError, e:
338                 status = messages.ERROR
339                 message = e.message
340                 messages.add_message(request, status, message)
341             except BaseException, e:
342                 status = messages.ERROR
343                 message = _('Something went wrong.')
344                 messages.add_message(request, status, message)
345     return render_response(template_name,
346                            signup_form = form,
347                            context_instance=get_context(request, extra_context))
348
349 @login_required
350 @signed_terms_required
351 def feedback(request, template_name='im/feedback.html', email_template_name='im/feedback_mail.txt', extra_context={}):
352     """
353     Allows a user to send feedback.
354     
355     In case of GET request renders a form for providing the feedback information.
356     In case of POST sends an email to support team.
357     
358     If the user isn't logged in, redirects to settings.LOGIN_URL.
359     
360     **Arguments**
361     
362     ``template_name``
363         A custom template to use. This is optional; if not specified,
364         this will default to ``im/feedback.html``.
365     
366     ``extra_context``
367         An dictionary of variables to add to the template context.
368     
369     **Template:**
370     
371     im/signup.html or ``template_name`` keyword argument.
372     
373     **Settings:**
374     
375     * LOGIN_URL: login uri
376     * ASTAKOS_DEFAULT_CONTACT_EMAIL: List of feedback recipients
377     """
378     if request.method == 'GET':
379         form = FeedbackForm()
380     if request.method == 'POST':
381         if not request.user:
382             return HttpResponse('Unauthorized', status=401)
383         
384         form = FeedbackForm(request.POST)
385         if form.is_valid():
386             msg = form.cleaned_data['feedback_msg'],
387             data = form.cleaned_data['feedback_data']
388             try:
389                 send_feedback(msg, data, request.user, email_template_name)
390             except SendMailError, e:
391                 message = e.message
392                 status = messages.ERROR
393             else:
394                 message = _('Feedback successfully sent')
395                 status = messages.SUCCESS
396             messages.add_message(request, status, message)
397     return render_response(template_name,
398                            feedback_form = form,
399                            context_instance = get_context(request, extra_context))
400
401 def logout(request, template='registration/logged_out.html', extra_context={}):
402     """
403     Wraps `django.contrib.auth.logout` and delete the cookie.
404     """
405     auth_logout(request)
406     response = HttpResponse()
407     response.delete_cookie(COOKIE_NAME, path='/', domain=COOKIE_DOMAIN)
408     next = request.GET.get('next')
409     if next:
410         response['Location'] = next
411         response.status_code = 302
412         return response
413     elif LOGOUT_NEXT:
414         response['Location'] = LOGOUT_NEXT
415         response.status_code = 301
416         return response
417     messages.add_message(request, messages.SUCCESS, _('You have successfully logged out.'))
418     context = get_context(request, extra_context)
419     response.write(render_to_string(template, context_instance=context))
420     return response
421
422 @transaction.commit_manually
423 def activate(request, email_template_name='im/welcome_email.txt', on_failure=''):
424     """
425     Activates the user identified by the ``auth`` request parameter, sends a welcome email
426     and renews the user token.
427     
428     The view uses commit_manually decorator in order to ensure the user state will be updated
429     only if the email will be send successfully.
430     """
431     token = request.GET.get('auth')
432     next = request.GET.get('next')
433     try:
434         user = AstakosUser.objects.get(auth_token=token)
435     except AstakosUser.DoesNotExist:
436         return HttpResponseBadRequest(_('No such user'))
437     
438     user.is_active = True
439     user.email_verified = True
440     user.save()
441     try:
442         send_greeting(user, email_template_name)
443         response = prepare_response(request, user, next, renew=True)
444         transaction.commit()
445         return response
446     except SendEmailError, e:
447         message = e.message
448         messages.add_message(request, messages.ERROR, message)
449         transaction.rollback()
450         return signup(request, on_failure='im/signup.html')
451     except BaseException, e:
452         status = messages.ERROR
453         message = _('Something went wrong.')
454         logger.exception(e)
455         transaction.rollback()
456         return signup(request, on_failure='im/signup.html')
457
458 def approval_terms(request, term_id=None, template_name='im/approval_terms.html', extra_context={}):
459     term = None
460     terms = None
461     if not term_id:
462         try:
463             term = ApprovalTerms.objects.order_by('-id')[0]
464         except IndexError:
465             pass
466     else:
467         try:
468              term = ApprovalTerms.objects.get(id=term_id)
469         except ApprovalTermDoesNotExist, e:
470             pass
471     
472     if not term:
473         return HttpResponseBadRequest(_('No approval terms found.'))
474     f = open(term.location, 'r')
475     terms = f.read()
476     
477     if request.method == 'POST':
478         next = request.POST.get('next')
479         if not next:
480             next = reverse('astakos.im.views.index')
481         form = SignApprovalTermsForm(request.POST, instance=request.user)
482         if not form.is_valid():
483             return render_response(template_name,
484                            terms = terms,
485                            approval_terms_form = form,
486                            context_instance = get_context(request, extra_context))
487         user = form.save()
488         return HttpResponseRedirect(next)
489     else:
490         form = None
491         if request.user.is_authenticated() and not has_signed_terms(request.user):
492             form = SignApprovalTermsForm(instance=request.user)
493         return render_response(template_name,
494                                terms = terms,
495                                approval_terms_form = form,
496                                context_instance = get_context(request, extra_context))
497
498 @signed_terms_required
499 def change_password(request):
500     return password_change(request, post_change_redirect=reverse('astakos.im.views.edit_profile'))