Customize third party signup form fields
[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                 logger.exception(e)
360                 status = messages.ERROR
361                 message = _('Something went wrong.')
362                 messages.add_message(request, status, message)
363                 logger.exception(e)
364     return render_response(template_name,
365                            signup_form = form,
366                            provider = provider,
367                            context_instance=get_context(request, extra_context))
368
369 @require_http_methods(["GET", "POST"])
370 @login_required
371 @signed_terms_required
372 def feedback(request, template_name='im/feedback.html', email_template_name='im/feedback_mail.txt', extra_context={}):
373     """
374     Allows a user to send feedback.
375
376     In case of GET request renders a form for providing the feedback information.
377     In case of POST sends an email to support team.
378
379     If the user isn't logged in, redirects to settings.LOGIN_URL.
380
381     **Arguments**
382
383     ``template_name``
384         A custom template to use. This is optional; if not specified,
385         this will default to ``im/feedback.html``.
386
387     ``extra_context``
388         An dictionary of variables to add to the template context.
389
390     **Template:**
391
392     im/signup.html or ``template_name`` keyword argument.
393
394     **Settings:**
395
396     * LOGIN_URL: login uri
397     * ASTAKOS_DEFAULT_CONTACT_EMAIL: List of feedback recipients
398     """
399     if request.method == 'GET':
400         form = FeedbackForm()
401     if request.method == 'POST':
402         if not request.user:
403             return HttpResponse('Unauthorized', status=401)
404
405         form = FeedbackForm(request.POST)
406         if form.is_valid():
407             msg = form.cleaned_data['feedback_msg']
408             data = form.cleaned_data['feedback_data']
409             try:
410                 send_feedback(msg, data, request.user, email_template_name)
411             except SendMailError, e:
412                 message = e.message
413                 status = messages.ERROR
414             else:
415                 message = _('Feedback successfully sent')
416                 status = messages.SUCCESS
417             messages.add_message(request, status, message)
418     return render_response(template_name,
419                            feedback_form = form,
420                            context_instance = get_context(request, extra_context))
421
422 @require_http_methods(["GET", "POST"])
423 def logout(request, template='registration/logged_out.html', extra_context={}):
424     """
425     Wraps `django.contrib.auth.logout` and delete the cookie.
426     """
427     response = HttpResponse()
428     if request.user.is_authenticated():
429         email = request.user.email
430         auth_logout(request)
431         response.delete_cookie(COOKIE_NAME, path='/', domain=COOKIE_DOMAIN)
432         msg = 'Cookie deleted for %s' % email
433         logger._log(LOGGING_LEVEL, msg, [])
434     next = request.GET.get('next')
435     if next:
436         response['Location'] = next
437         response.status_code = 302
438         return response
439     elif LOGOUT_NEXT:
440         response['Location'] = LOGOUT_NEXT
441         response.status_code = 301
442         return response
443     messages.add_message(request, messages.SUCCESS, _('<p>You have successfully logged out.</p>'))
444     context = get_context(request, extra_context)
445     response.write(render_to_string(template, context_instance=context))
446     return response
447
448 @require_http_methods(["GET", "POST"])
449 @transaction.commit_manually
450 def activate(request, greeting_email_template_name='im/welcome_email.txt', helpdesk_email_template_name='im/helpdesk_notification.txt'):
451     """
452     Activates the user identified by the ``auth`` request parameter, sends a welcome email
453     and renews the user token.
454
455     The view uses commit_manually decorator in order to ensure the user state will be updated
456     only if the email will be send successfully.
457     """
458     token = request.GET.get('auth')
459     next = request.GET.get('next')
460     try:
461         user = AstakosUser.objects.get(auth_token=token)
462     except AstakosUser.DoesNotExist:
463         return HttpResponseBadRequest(_('No such user'))
464     
465     if user.is_active:
466         message = _('Account already active.')
467         messages.add_message(request, messages.ERROR, message)
468         return index(request)
469     
470     try:
471         activate_func(user, greeting_email_template_name, helpdesk_email_template_name, verify_email=True)
472         response = prepare_response(request, user, next, renew=True)
473         transaction.commit()
474         return response
475     except SendMailError, e:
476         message = e.message
477         messages.add_message(request, messages.ERROR, message)
478         transaction.rollback()
479         return index(request)
480     except BaseException, e:
481         status = messages.ERROR
482         message = _('Something went wrong.')
483         messages.add_message(request, messages.ERROR, message)
484         logger.exception(e)
485         transaction.rollback()
486         return index(request)
487
488 @require_http_methods(["GET", "POST"])
489 def approval_terms(request, term_id=None, template_name='im/approval_terms.html', extra_context={}):
490     term = None
491     terms = None
492     if not term_id:
493         try:
494             term = ApprovalTerms.objects.order_by('-id')[0]
495         except IndexError:
496             pass
497     else:
498         try:
499              term = ApprovalTerms.objects.get(id=term_id)
500         except ApprovalTermDoesNotExist, e:
501             pass
502
503     if not term:
504         return HttpResponseRedirect(reverse('astakos.im.views.index'))
505     f = open(term.location, 'r')
506     terms = f.read()
507
508     if request.method == 'POST':
509         next = request.POST.get('next')
510         if not next:
511             next = reverse('astakos.im.views.index')
512         form = SignApprovalTermsForm(request.POST, instance=request.user)
513         if not form.is_valid():
514             return render_response(template_name,
515                            terms = terms,
516                            approval_terms_form = form,
517                            context_instance = get_context(request, extra_context))
518         user = form.save()
519         return HttpResponseRedirect(next)
520     else:
521         form = None
522         if request.user.is_authenticated() and not request.user.signed_terms():
523             form = SignApprovalTermsForm(instance=request.user)
524         return render_response(template_name,
525                                terms = terms,
526                                approval_terms_form = form,
527                                context_instance = get_context(request, extra_context))
528
529 @require_http_methods(["GET", "POST"])
530 @signed_terms_required
531 def change_password(request):
532     return password_change(request,
533                             post_change_redirect=reverse('astakos.im.views.edit_profile'),
534                             password_change_form=ExtendedPasswordChangeForm)
535
536 @require_http_methods(["GET", "POST"])
537 @login_required
538 @signed_terms_required
539 @transaction.commit_manually
540 def change_email(request, activation_key=None,
541                  email_template_name='registration/email_change_email.txt',
542                  form_template_name='registration/email_change_form.html',
543                  confirm_template_name='registration/email_change_done.html',
544                  extra_context={}):
545     if activation_key:
546         try:
547             user = EmailChange.objects.change_email(activation_key)
548             if request.user.is_authenticated() and request.user == user:
549                 msg = _('Email changed successfully.')
550                 messages.add_message(request, messages.SUCCESS, msg)
551                 auth_logout(request)
552                 response = prepare_response(request, user)
553                 transaction.commit()
554                 return response
555         except ValueError, e:
556             messages.add_message(request, messages.ERROR, e)
557         return render_response(confirm_template_name,
558                                modified_user = user if 'user' in locals() else None,
559                                context_instance = get_context(request,
560                                                               extra_context))
561     
562     if not request.user.is_authenticated():
563         path = quote(request.get_full_path())
564         url = request.build_absolute_uri(reverse('astakos.im.views.index'))
565         return HttpResponseRedirect(url + '?next=' + path)
566     form = EmailChangeForm(request.POST or None)
567     if request.method == 'POST' and form.is_valid():
568         try:
569             ec = form.save(email_template_name, request)
570         except SendMailError, e:
571             status = messages.ERROR
572             msg = e
573             transaction.rollback()
574         except IntegrityError, e:
575             status = messages.ERROR
576             msg = _('There is already a pending change email request.')
577         else:
578             status = messages.SUCCESS
579             msg = _('Change email request has been registered succefully.\
580                     You are going to receive a verification email in the new address.')
581             transaction.commit()
582         messages.add_message(request, status, msg)
583     return render_response(form_template_name,
584                            form = form,
585                            context_instance = get_context(request,
586                                                           extra_context))