Force user to accept service terms
[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.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
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     formclass = 'LoginForm'
136     kwargs = {}
137     if request.user.is_authenticated():
138         return HttpResponseRedirect(reverse('astakos.im.views.edit_profile'))
139     return render_response(template_name,
140                            form = globals()[formclass](**kwargs),
141                            context_instance = get_context(request, extra_context))
142
143 @login_required
144 @signed_terms_required
145 @transaction.commit_manually
146 def invite(request, template_name='im/invitations.html', extra_context={}):
147     """
148     Allows a user to invite somebody else.
149     
150     In case of GET request renders a form for providing the invitee information.
151     In case of POST checks whether the user has not run out of invitations and then
152     sends an invitation email to singup to the service.
153     
154     The view uses commit_manually decorator in order to ensure the number of the
155     user invitations is going to be updated only if the email has been successfully sent.
156     
157     If the user isn't logged in, redirects to settings.LOGIN_URL.
158     
159     **Arguments**
160     
161     ``template_name``
162         A custom template to use. This is optional; if not specified,
163         this will default to ``im/invitations.html``.
164     
165     ``extra_context``
166         An dictionary of variables to add to the template context.
167     
168     **Template:**
169     
170     im/invitations.html or ``template_name`` keyword argument.
171     
172     **Settings:**
173     
174     The view expectes the following settings are defined:
175     
176     * LOGIN_URL: login uri
177     * ASTAKOS_DEFAULT_CONTACT_EMAIL: service support email
178     * ASTAKOS_DEFAULT_FROM_EMAIL: from email
179     """
180     status = None
181     message = None
182     inviter = AstakosUser.objects.get(username = request.user.username)
183     
184     if request.method == 'POST':
185         username = request.POST.get('uniq')
186         realname = request.POST.get('realname')
187         
188         if inviter.invitations > 0:
189             try:
190                 invite_func(inviter, username, realname)
191                 status = messages.SUCCESS
192                 message = _('Invitation sent to %s' % username)
193                 transaction.commit()
194             except (SMTPException, socket.error) as e:
195                 status = messages.ERROR
196                 message = getattr(e, 'strerror', '')
197                 transaction.rollback()
198             except IntegrityError, e:
199                 status = messages.ERROR
200                 message = _('There is already invitation for %s' % username)
201                 transaction.rollback()
202         else:
203             status = messages.ERROR
204             message = _('No invitations left')
205     messages.add_message(request, status, message)
206     
207     sent = [{'email': inv.username,
208              'realname': inv.realname,
209              'is_consumed': inv.is_consumed}
210              for inv in inviter.invitations_sent.all()]
211     kwargs = {'inviter': inviter,
212               'sent':sent}
213     context = get_context(request, extra_context, **kwargs)
214     return render_response(template_name,
215                            context_instance = context)
216
217 @login_required
218 @signed_terms_required
219 def edit_profile(request, template_name='im/profile.html', extra_context={}):
220     """
221     Allows a user to edit his/her profile.
222     
223     In case of GET request renders a form for displaying the user information.
224     In case of POST updates the user informantion and redirects to ``next``
225     url parameter if exists.
226     
227     If the user isn't logged in, redirects to settings.LOGIN_URL.
228     
229     **Arguments**
230     
231     ``template_name``
232         A custom template to use. This is optional; if not specified,
233         this will default to ``im/profile.html``.
234     
235     ``extra_context``
236         An dictionary of variables to add to the template context.
237     
238     **Template:**
239     
240     im/profile.html or ``template_name`` keyword argument.
241     
242     **Settings:**
243     
244     The view expectes the following settings are defined:
245     
246     * LOGIN_URL: login uri
247     """
248     form = ProfileForm(instance=request.user)
249     extra_context['next'] = request.GET.get('next')
250     reset_cookie = False
251     if request.method == 'POST':
252         form = ProfileForm(request.POST, instance=request.user)
253         if form.is_valid():
254             try:
255                 prev_token = request.user.auth_token
256                 user = form.save()
257                 reset_cookie = user.auth_token != prev_token
258                 form = ProfileForm(instance=user)
259                 next = request.POST.get('next')
260                 if next:
261                     return redirect(next)
262                 msg = _('Profile has been updated successfully')
263                 messages.add_message(request, messages.SUCCESS, msg)
264             except ValueError, ve:
265                 messages.add_message(request, messages.ERROR, ve)
266     return render_response(template_name,
267                            reset_cookie = reset_cookie,
268                            form = form,
269                            context_instance = get_context(request,
270                                                           extra_context))
271
272 def signup(request, on_failure='im/signup.html', on_success='im/signup_complete.html', extra_context={}, backend=None):
273     """
274     Allows a user to create a local account.
275     
276     In case of GET request renders a form for providing the user information.
277     In case of POST handles the signup.
278     
279     The user activation will be delegated to the backend specified by the ``backend`` keyword argument
280     if present, otherwise to the ``astakos.im.backends.InvitationBackend``
281     if settings.ASTAKOS_INVITATIONS_ENABLED is True or ``astakos.im.backends.SimpleBackend`` if not
282     (see backends);
283     
284     Upon successful user creation if ``next`` url parameter is present the user is redirected there
285     otherwise renders the same page with a success message.
286     
287     On unsuccessful creation, renders ``on_failure`` with an error message.
288     
289     **Arguments**
290     
291     ``on_failure``
292         A custom template to render in case of failure. This is optional;
293         if not specified, this will default to ``im/signup.html``.
294     
295     
296     ``on_success``
297         A custom template to render in case of success. This is optional;
298         if not specified, this will default to ``im/signup_complete.html``.
299     
300     ``extra_context``
301         An dictionary of variables to add to the template context.
302     
303     **Template:**
304     
305     im/signup.html or ``on_failure`` keyword argument.
306     im/signup_complete.html or ``on_success`` keyword argument. 
307     """
308     if request.user.is_authenticated():
309         return HttpResponseRedirect(reverse('astakos.im.views.index'))
310     try:
311         if not backend:
312             backend = get_backend(request)
313         for provider in IM_MODULES:
314             extra_context['%s_form' % provider] = backend.get_signup_form(provider)
315         if request.method == 'POST':
316             provider = request.POST.get('provider')
317             next = request.POST.get('next', '')
318             form = extra_context['%s_form' % provider]
319             if form.is_valid():
320                 if provider != 'local':
321                     url = reverse('astakos.im.target.%s.login' % provider)
322                     url = '%s?email=%s&next=%s' % (url, form.data['email'], next)
323                     if backend.invitation:
324                         url = '%s&code=%s' % (url, backend.invitation.code)
325                     return redirect(url)
326                 else:
327                     status, message, user = backend.signup(form)
328                     if user and user.is_active:
329                         return prepare_response(request, user, next=next)
330                     messages.add_message(request, status, message)
331                     return render_response(on_success,
332                                            context_instance=get_context(request, extra_context))
333     except (Invitation.DoesNotExist, ValueError), e:
334         messages.add_message(request, messages.ERROR, e)
335         for provider in IM_MODULES:
336             main = provider.capitalize() if provider == 'local' else 'ThirdParty'
337             formclass = '%sUserCreationForm' % main
338             extra_context['%s_form' % provider] = globals()[formclass]()
339     return render_response(on_failure,
340                            context_instance=get_context(request, extra_context))
341
342 @login_required
343 @signed_terms_required
344 def send_feedback(request, template_name='im/feedback.html', email_template_name='im/feedback_mail.txt', extra_context={}):
345     """
346     Allows a user to send feedback.
347     
348     In case of GET request renders a form for providing the feedback information.
349     In case of POST sends an email to support team.
350     
351     If the user isn't logged in, redirects to settings.LOGIN_URL.
352     
353     **Arguments**
354     
355     ``template_name``
356         A custom template to use. This is optional; if not specified,
357         this will default to ``im/feedback.html``.
358     
359     ``extra_context``
360         An dictionary of variables to add to the template context.
361     
362     **Template:**
363     
364     im/signup.html or ``template_name`` keyword argument.
365     
366     **Settings:**
367     
368     * LOGIN_URL: login uri
369     * ASTAKOS_DEFAULT_CONTACT_EMAIL: List of feedback recipients
370     """
371     if request.method == 'GET':
372         form = FeedbackForm()
373     if request.method == 'POST':
374         if not request.user:
375             return HttpResponse('Unauthorized', status=401)
376         
377         form = FeedbackForm(request.POST)
378         if form.is_valid():
379             subject = _("Feedback from %s alpha2 testing" % SITENAME)
380             from_email = request.user.email
381             recipient_list = [DEFAULT_CONTACT_EMAIL]
382             content = render_to_string(email_template_name, {
383                         'message': form.cleaned_data['feedback_msg'],
384                         'data': form.cleaned_data['feedback_data'],
385                         'request': request})
386             
387             try:
388                 send_mail(subject, content, from_email, recipient_list)
389                 message = _('Feedback successfully sent')
390                 status = messages.SUCCESS
391             except (SMTPException, socket.error) as e:
392                 status = messages.ERROR
393                 message = getattr(e, 'strerror', '')
394             messages.add_message(request, status, message)
395     return render_response(template_name,
396                            form = form,
397                            context_instance = get_context(request, extra_context))
398
399 def logout(request, template='registration/logged_out.html', extra_context={}):
400     """
401     Wraps `django.contrib.auth.logout` and delete the cookie.
402     """
403     auth_logout(request)
404     response = HttpResponse()
405     response.delete_cookie(COOKIE_NAME, path='/', domain=COOKIE_DOMAIN)
406     next = request.GET.get('next')
407     if next:
408         response['Location'] = next
409         response.status_code = 302
410         return response
411     elif LOGOUT_NEXT:
412         response['Location'] = LOGOUT_NEXT
413         response.status_code = 301
414         return response
415     messages.add_message(request, messages.SUCCESS, _('You have successfully logged out.'))
416     context = get_context(request, extra_context)
417     response.write(render_to_string(template, context_instance=context))
418     return response
419
420 @transaction.commit_manually
421 def activate(request, email_template_name='im/welcome_email.txt', on_failure=''):
422     """
423     Activates the user identified by the ``auth`` request parameter, sends a welcome email
424     and renews the user token.
425     
426     The view uses commit_manually decorator in order to ensure the user state will be updated
427     only if the email will be send successfully.
428     """
429     token = request.GET.get('auth')
430     next = request.GET.get('next')
431     try:
432         user = AstakosUser.objects.get(auth_token=token)
433     except AstakosUser.DoesNotExist:
434         return HttpResponseBadRequest(_('No such user'))
435     
436     user.is_active = True
437     user.email_verified = True
438     user.save()
439     try:
440         send_greeting(user, email_template_name)
441         response = prepare_response(request, user, next, renew=True)
442         transaction.commit()
443         return response
444     except (SMTPException, socket.error) as e:
445         message = getattr(e, 'name') if hasattr(e, 'name') else e
446         messages.add_message(request, messages.ERROR, message)
447         transaction.rollback()
448         return signup(request, on_failure='im/signup.html')
449
450 def approval_terms(request, term_id=None, template_name='im/approval_terms.html', extra_context={}):
451     term = None
452     terms = None
453     if not term_id:
454         try:
455             term = ApprovalTerms.objects.order_by('-id')[0]
456         except IndexError:
457             pass
458     else:
459         try:
460              term = ApprovalTerms.objects.get(id=term_id)
461         except ApprovalTermDoesNotExist, e:
462             pass
463     
464     if not term:
465         return HttpResponseBadRequest(_('No approval terms found.'))
466     f = open(term.location, 'r')
467     terms = f.read()
468     
469     if request.method == 'POST':
470         next = request.POST.get('next')
471         if not next:
472             return HttpResponseBadRequest(_('No next param.'))
473         form = SignApprovalTermsForm(request.POST, instance=request.user)
474         if not form.is_valid():
475             return render_response(template_name,
476                            terms = terms,
477                            form = form,
478                            context_instance = get_context(request, extra_context))
479         user = form.save()
480         return HttpResponseRedirect(next)
481     else:
482         form = SignApprovalTermsForm(instance=request.user) if request.user.is_authenticated() else None
483         return render_response(template_name,
484                                terms = terms,
485                                form = form,
486                                context_instance = get_context(request, extra_context))
487
488 @signed_terms_required
489 def change_password(request):
490     return password_change(request, post_change_redirect=reverse('astakos.im.views.edit_profile'))