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