Merge branch 'develop' of git://github.com/melinath/philo into develop
[philo.git] / philo / contrib / waldo / models.py
1 """
2 Waldo provides abstract :class:`.MultiView`\ s to handle several levels of common authentication:
3
4 * :class:`LoginMultiView` handles the case where users only need to be able to log in and out.
5 * :class:`PasswordMultiView` handles the case where users will also need to change their password.
6 * :class:`RegistrationMultiView` builds on top of :class:`PasswordMultiView` to handle user registration, as well.
7 * :class:`AccountMultiView` adds account-handling functionality to the :class:`RegistrationMultiView`.
8
9 """
10
11 import urlparse
12
13 from django import forms
14 from django.conf.urls.defaults import url, patterns, include
15 from django.contrib import messages
16 from django.contrib.auth import authenticate, login, views as auth_views
17 from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm, PasswordChangeForm
18 from django.contrib.auth.models import User
19 from django.contrib.auth.tokens import default_token_generator as password_token_generator
20 from django.contrib.sites.models import Site
21 from django.core.mail import EmailMultiAlternatives, send_mail
22 from django.db import models
23 from django.http import Http404, HttpResponseRedirect
24 from django.shortcuts import render_to_response, get_object_or_404
25 from django.template.defaultfilters import striptags
26 from django.utils.http import int_to_base36, base36_to_int
27 from django.utils.translation import ugettext as _
28 from django.views.decorators.cache import never_cache
29 from django.views.decorators.csrf import csrf_protect
30
31 from philo.models import MultiView, Page
32 from philo.contrib.waldo.forms import WaldoAuthenticationForm, RegistrationForm, UserAccountForm
33 from philo.contrib.waldo.tokens import registration_token_generator, email_token_generator
34
35
36 class LoginMultiView(MultiView):
37         """Handles exclusively methods and views related to logging users in and out."""
38         #: A :class:`ForeignKey` to the :class:`.Page` which will be used to render the login form.
39         login_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_login_related')
40         #: A django form class which will be used for the authentication process. Default: :class:`.WaldoAuthenticationForm`.
41         login_form = WaldoAuthenticationForm
42         
43         @property
44         def urlpatterns(self):
45                 return patterns('',
46                         url(r'^login$', self.login, name='login'),
47                         url(r'^logout$', self.logout, name='logout'),
48                 )
49         
50         def set_requirement_redirect(self, request, redirect=None):
51                 """Figures out and stores where a user should end up after landing on a page (like the login page) because they have not fulfilled some kind of requirement."""
52                 if redirect is not None:
53                         pass
54                 elif 'requirement_redirect' in request.session:
55                         return
56                 else:
57                         referrer = request.META.get('HTTP_REFERER', None)
58                 
59                         if referrer is not None:
60                                 referrer = urlparse.urlparse(referrer)
61                                 host = referrer[1]
62                                 if host != request.get_host():
63                                         referrer = None
64                                 else:
65                                         redirect = '%s?%s' % (referrer[2], referrer[4])
66                 
67                         path = request.get_full_path()
68                         if referrer is None or redirect == path:
69                                 # Default to the index page if we can't find a referrer or
70                                 # if we'd otherwise redirect to where we already are.
71                                 redirect = request.node.get_absolute_url()
72                 
73                 request.session['requirement_redirect'] = redirect
74         
75         def get_requirement_redirect(self, request, default=None):
76                 """Returns the location which a user should be redirected to after fulfilling a requirement (like logging in)."""
77                 redirect = request.session.pop('requirement_redirect', None)
78                 # Security checks a la django.contrib.auth.views.login
79                 if not redirect or ' ' in redirect:
80                         redirect = default
81                 else:
82                         netloc = urlparse.urlparse(redirect)[1]
83                         if netloc and netloc != request.get_host():
84                                 redirect = default
85                 if redirect is None:
86                         redirect = request.node.get_absolute_url()
87                 return redirect
88         
89         @never_cache
90         def login(self, request, extra_context=None):
91                 """Renders the :attr:`login_page` with an instance of the :attr:`login_form` for the given :class:`HttpRequest`."""
92                 self.set_requirement_redirect(request)
93                 
94                 # Redirect already-authenticated users to the index page.
95                 if request.user.is_authenticated():
96                         messages.add_message(request, messages.INFO, "You are already authenticated. Please log out if you wish to log in as a different user.")
97                         return HttpResponseRedirect(self.get_requirement_redirect(request))
98                 
99                 if request.method == 'POST':
100                         form = self.login_form(request=request, data=request.POST)
101                         if form.is_valid():
102                                 redirect = self.get_requirement_redirect(request)
103                                 login(request, form.get_user())
104                                 
105                                 if request.session.test_cookie_worked():
106                                         request.session.delete_test_cookie()
107                                 
108                                 return HttpResponseRedirect(redirect)
109                 else:
110                         form = self.login_form()
111                 
112                 request.session.set_test_cookie()
113                 
114                 context = self.get_context()
115                 context.update(extra_context or {})
116                 context.update({
117                         'form': form
118                 })
119                 return self.login_page.render_to_response(request, extra_context=context)
120         
121         @never_cache
122         def logout(self, request, extra_context=None):
123                 """Logs the given :class:`HttpRequest` out, redirecting the user to the page they just left or to the :meth:`~.Node.get_absolute_url` for the ``request.node``."""
124                 return auth_views.logout(request, request.META.get('HTTP_REFERER', request.node.get_absolute_url()))
125         
126         def login_required(self, view):
127                 """Wraps a view function to require that the user be logged in."""
128                 def inner(request, *args, **kwargs):
129                         if not request.user.is_authenticated():
130                                 self.set_requirement_redirect(request, redirect=request.path)
131                                 if request.POST:
132                                         messages.add_message(request, messages.ERROR, "Please log in again, because your session has expired.")
133                                 return HttpResponseRedirect(self.reverse('login', node=request.node))
134                         return view(request, *args, **kwargs)
135                 
136                 return inner
137         
138         class Meta:
139                 abstract = True
140
141
142 class PasswordMultiView(LoginMultiView):
143         """
144         Adds support for password setting, resetting, and changing to the :class:`LoginMultiView`. Password reset support includes handling of a confirmation email.
145         
146         """
147         #: A :class:`ForeignKey` to the :class:`.Page` which will be used to render the password reset request form.
148         password_reset_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_reset_related', blank=True, null=True)
149         #: A :class:`ForeignKey` to the :class:`.Page` which will be used to render the password reset confirmation email.
150         password_reset_confirmation_email = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_reset_confirmation_email_related', blank=True, null=True)
151         #: A :class:`ForeignKey` to the :class:`.Page` which will be used to render the password setting form (i.e. the page that users will see after confirming a password reset).
152         password_set_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_set_related', blank=True, null=True)
153         #: A :class:`ForeignKey` to the :class:`.Page` which will be used to render the password change form.
154         password_change_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_change_related', blank=True, null=True)
155         
156         #: The password change form class. Default: :class:`django.contrib.auth.forms.PasswordChangeForm`.
157         password_change_form = PasswordChangeForm
158         #: The password set form class. Default: :class:`django.contrib.auth.forms.SetPasswordForm`.
159         password_set_form = SetPasswordForm
160         #: The password reset request form class. Default: :class:`django.contrib.auth.forms.PasswordResetForm`.
161         password_reset_form = PasswordResetForm
162         
163         @property
164         def urlpatterns(self):
165                 urlpatterns = super(PasswordMultiView, self).urlpatterns
166                 
167                 if self.password_reset_page_id and self.password_reset_confirmation_email_id and self.password_set_page_id:
168                         urlpatterns += patterns('',
169                                 url(r'^password/reset$', csrf_protect(self.password_reset), name='password_reset'),
170                                 url(r'^password/reset/(?P<uidb36>\w+)/(?P<token>[^/]+)$', self.password_reset_confirm, name='password_reset_confirm'),
171                         )
172                 
173                 if self.password_change_page_id:
174                         urlpatterns += patterns('',
175                                 url(r'^password/change$', csrf_protect(self.login_required(self.password_change)), name='password_change'),
176                         )
177                 return urlpatterns
178         
179         def make_confirmation_link(self, confirmation_view, token_generator, user, node, token_args=None, reverse_kwargs=None, secure=False):
180                 """
181                 Generates a confirmation link for an arbitrary action, such as a password reset.
182                 
183                 :param confirmation_view: The view function which needs to be linked to.
184                 :param token_generator: Generates a confirmable token for the action.
185                 :param user: The user who is trying to take the action.
186                 :param node: The node which is providing the basis for the confirmation URL.
187                 :param token_args: A list of additional arguments (i.e. besides the user) to be used for token creation.
188                 :param reverse_kwargs: A dictionary of any additional keyword arguments necessary for correctly reversing the view.
189                 :param secure: Whether the link should use the https:// or http://.
190                 
191                 """
192                 token = token_generator.make_token(user, *(token_args or []))
193                 kwargs = {
194                         'uidb36': int_to_base36(user.id),
195                         'token': token
196                 }
197                 kwargs.update(reverse_kwargs or {})
198                 return node.construct_url(subpath=self.reverse(confirmation_view, kwargs=kwargs), with_domain=True, secure=secure)
199         
200         def send_confirmation_email(self, subject, email, page, extra_context):
201                 """
202                 Sends a confirmation email for an arbitrary action, such as a password reset. If the ``page``'s :class:`.Template` has a mimetype of ``text/html``, then the email will be sent with an HTML alternative version.
203                 
204                 :param subject: The subject line of the email.
205                 :param email: The recipient's address.
206                 :param page: The page which will be used to render the email body.
207                 :param extra_context: The context for rendering the ``page``.
208                 
209                 """
210                 text_content = page.render_to_string(extra_context=extra_context)
211                 from_email = 'noreply@%s' % Site.objects.get_current().domain
212                 
213                 if page.template.mimetype == 'text/html':
214                         msg = EmailMultiAlternatives(subject, striptags(text_content), from_email, [email])
215                         msg.attach_alternative(text_content, 'text/html')
216                         msg.send()
217                 else:
218                         send_mail(subject, text_content, from_email, [email])
219         
220         def password_reset(self, request, extra_context=None, token_generator=password_token_generator):
221                 """
222                 Handles the process by which users request a password reset, and generates the context for the confirmation email. That context will contain:
223                 
224                 link
225                         The confirmation link for the password reset.
226                 user
227                         The user requesting the reset.
228                 site
229                         The current :class:`Site`.
230                 request
231                         The current :class:`HttpRequest` instance.
232                 
233                 :param token_generator: The token generator to use for the confirmation link.
234                 
235                 """
236                 if request.user.is_authenticated():
237                         return HttpResponseRedirect(request.node.get_absolute_url())
238                 
239                 if request.method == 'POST':
240                         form = self.password_reset_form(request.POST)
241                         if form.is_valid():
242                                 current_site = Site.objects.get_current()
243                                 for user in form.users_cache:
244                                         context = {
245                                                 'link': self.make_confirmation_link('password_reset_confirm', token_generator, user, request.node, secure=request.is_secure()),
246                                                 'user': user,
247                                                 'site': current_site,
248                                                 'request': request
249                                         }
250                                         self.send_confirmation_email('Confirm password reset for account at %s' % current_site.domain, user.email, self.password_reset_confirmation_email, context)
251                                         messages.add_message(request, messages.SUCCESS, "An email has been sent to the address you provided with details on resetting your password.", fail_silently=True)
252                                 return HttpResponseRedirect('')
253                 else:
254                         form = self.password_reset_form()
255                 
256                 context = self.get_context()
257                 context.update(extra_context or {})
258                 context.update({
259                         'form': form
260                 })
261                 return self.password_reset_page.render_to_response(request, extra_context=context)
262         
263         def password_reset_confirm(self, request, extra_context=None, uidb36=None, token=None, token_generator=password_token_generator):
264                 """
265                 Checks that ``token``` is valid, and if so, renders an instance of :attr:`password_set_form` with :attr:`password_set_page`.
266                 
267                 :param token_generator: The token generator used to check the ``token``.
268                 
269                 """
270                 assert uidb36 is not None and token is not None
271                 try:
272                         uid_int = base36_to_int(uidb36)
273                 except:
274                         raise Http404
275                 
276                 user = get_object_or_404(User, id=uid_int)
277                 
278                 if token_generator.check_token(user, token):
279                         if request.method == 'POST':
280                                 form = self.password_set_form(user, request.POST)
281                                 
282                                 if form.is_valid():
283                                         form.save()
284                                         messages.add_message(request, messages.SUCCESS, "Password reset successful.")
285                                         return HttpResponseRedirect(self.reverse('login', node=request.node))
286                         else:
287                                 form = self.password_set_form(user)
288                         
289                         context = self.get_context()
290                         context.update(extra_context or {})
291                         context.update({
292                                 'form': form
293                         })
294                         return self.password_set_page.render_to_response(request, extra_context=context)
295                 
296                 raise Http404
297         
298         def password_change(self, request, extra_context=None):
299                 """Renders an instance of :attr:`password_change_form` with :attr:`password_change_page`."""
300                 if request.method == 'POST':
301                         form = self.password_change_form(request.user, request.POST)
302                         if form.is_valid():
303                                 form.save()
304                                 messages.add_message(request, messages.SUCCESS, 'Password changed successfully.', fail_silently=True)
305                                 return HttpResponseRedirect('')
306                 else:
307                         form = self.password_change_form(request.user)
308                 
309                 context = self.get_context()
310                 context.update(extra_context or {})
311                 context.update({
312                         'form': form
313                 })
314                 return self.password_change_page.render_to_response(request, extra_context=context)
315         
316         class Meta:
317                 abstract = True
318
319
320 class RegistrationMultiView(PasswordMultiView):
321         """Adds support for user registration to the :class:`PasswordMultiView`."""
322         #: A :class:`ForeignKey` to the :class:`.Page` which will be used to display the registration form.
323         register_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_register_related', blank=True, null=True)
324         #: A :class:`ForeignKey` to the :class:`.Page` which will be used to render the registration confirmation email.
325         register_confirmation_email = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_register_confirmation_email_related', blank=True, null=True)
326         #: The registration form class. Default: :class:`.RegistrationForm`.
327         registration_form = RegistrationForm
328         
329         @property
330         def urlpatterns(self):
331                 urlpatterns = super(RegistrationMultiView, self).urlpatterns
332                 if self.register_page_id and self.register_confirmation_email_id:
333                         urlpatterns += patterns('',
334                                 url(r'^register$', csrf_protect(self.register), name='register'),
335                                 url(r'^register/(?P<uidb36>\w+)/(?P<token>[^/]+)$', self.register_confirm, name='register_confirm')
336                         )
337                 return urlpatterns
338         
339         def register(self, request, extra_context=None, token_generator=registration_token_generator):
340                 """
341                 Renders the :attr:`register_page` with an instance of :attr:`registration_form` in the context as ``form``. If the form has been submitted, sends a confirmation email using :attr:`register_confirmation_email` and the same context as :meth:`PasswordMultiView.password_reset`.
342                 
343                 :param token_generator: The token generator to use for the confirmation link.
344                 
345                 """
346                 if request.user.is_authenticated():
347                         return HttpResponseRedirect(request.node.get_absolute_url())
348                 
349                 if request.method == 'POST':
350                         form = self.registration_form(request.POST)
351                         if form.is_valid():
352                                 user = form.save()
353                                 current_site = Site.objects.get_current()
354                                 context = {
355                                         'link': self.make_confirmation_link('register_confirm', token_generator, user, request.node, secure=request.is_secure()),
356                                         'user': user,
357                                         'site': current_site,
358                                         'request': request
359                                 }
360                                 self.send_confirmation_email('Confirm account creation at %s' % current_site.name, user.email, self.register_confirmation_email, context)
361                                 messages.add_message(request, messages.SUCCESS, 'An email has been sent to %s with details on activating your account.' % user.email, fail_silently=True)
362                                 return HttpResponseRedirect(request.node.get_absolute_url())
363                 else:
364                         form = self.registration_form()
365                 
366                 context = self.get_context()
367                 context.update(extra_context or {})
368                 context.update({
369                         'form': form
370                 })
371                 return self.register_page.render_to_response(request, extra_context=context)
372         
373         def register_confirm(self, request, extra_context=None, uidb36=None, token=None, token_generator=registration_token_generator):
374                 """
375                 Checks that ``token`` is valid, and if so, logs the user in and redirects them to :meth:`post_register_confirm_redirect`.
376                 
377                 :param token_generator: The token generator used to check the ``token``.
378                 """
379                 assert uidb36 is not None and token is not None
380                 try:
381                         uid_int = base36_to_int(uidb36)
382                 except:
383                         raise Http404
384                 
385                 user = get_object_or_404(User, id=uid_int)
386                 if token_generator.check_token(user, token):
387                         user.is_active = True
388                         true_password = user.password
389                         temp_password = token_generator.make_token(user)
390                         try:
391                                 user.set_password(temp_password)
392                                 user.save()
393                                 authenticated_user = authenticate(username=user.username, password=temp_password)
394                                 login(request, authenticated_user)
395                         finally:
396                                 # if anything goes wrong, do our best make sure that the true password is restored.
397                                 user.password = true_password
398                                 user.save()
399                         return self.post_register_confirm_redirect(request)
400                 
401                 raise Http404
402         
403         def post_register_confirm_redirect(self, request):
404                 """Returns an :class:`HttpResponseRedirect` for post-registration-confirmation. Default: :meth:`Node.get_absolute_url` for ``request.node``."""
405                 return HttpResponseRedirect(request.node.get_absolute_url())
406         
407         class Meta:
408                 abstract = True
409
410
411 class AccountMultiView(RegistrationMultiView):
412         """Adds support for user accounts on top of the :class:`RegistrationMultiView`. By default, the account consists of the first_name, last_name, and email fields of the User model. Using a different account model is as simple as replacing :attr:`account_form` with any form class that takes an :class:`auth.User` instance as the first argument."""
413         #: A :class:`ForeignKey` to the :class:`Page` which will be used to render the account management form.
414         manage_account_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_manage_account_related', blank=True, null=True)
415         #: A :class:`ForeignKey` to a :class:`Page` which will be used to render an email change confirmation email. This is optional; if it is left blank, then email changes will be performed without confirmation.
416         email_change_confirmation_email = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_email_change_confirmation_email_related', blank=True, null=True, help_text="If this is left blank, email changes will be performed without confirmation.")
417         
418         #: A django form class which will be used to manage the user's account. Default: :class:`.UserAccountForm`
419         account_form = UserAccountForm
420         
421         @property
422         def urlpatterns(self):
423                 urlpatterns = super(AccountMultiView, self).urlpatterns
424                 if self.manage_account_page_id:
425                         urlpatterns += patterns('',
426                                 url(r'^account$', self.login_required(self.account_view), name='account'),
427                         )
428                 if self.email_change_confirmation_email_id:
429                         urlpatterns += patterns('',
430                                 url(r'^account/email/(?P<uidb36>\w+)/(?P<email>[\w.]+[+][\w.]+)/(?P<token>[^/]+)$', self.email_change_confirm, name='email_change_confirm')
431                         )
432                 return urlpatterns
433         
434         def account_view(self, request, extra_context=None, token_generator=email_token_generator, *args, **kwargs):
435                 """
436                 Renders the :attr:`manage_account_page` with an instance of :attr:`account_form` in the context as ``form``. If the form has been posted, the user's email was changed, and :attr:`email_change_confirmation_email` is not ``None``, sends a confirmation email to the new email to make sure it exists before making the change. The email will have the same context as :meth:`PasswordMultiView.password_reset`.
437                 
438                 :param token_generator: The token generator to use for the confirmation link. 
439                 
440                 """
441                 if request.method == 'POST':
442                         form = self.account_form(request.user, request.POST, request.FILES)
443                         
444                         if form.is_valid():
445                                 message = "Account information saved."
446                                 redirect = self.get_requirement_redirect(request, default='')
447                                 if 'email' in form.changed_data and self.email_change_confirmation_email:
448                                         # ModelForms modify their instances in-place during
449                                         # validation, so reset the instance's email to its
450                                         # previous value here, then remove the new value
451                                         # from cleaned_data. We only do this if an email
452                                         # change confirmation email is available.
453                                         request.user.email = form.initial['email']
454                                         
455                                         email = form.cleaned_data.pop('email')
456                                         
457                                         current_site = Site.objects.get_current()
458                                         
459                                         context = {
460                                                 'link': self.make_confirmation_link('email_change_confirm', token_generator, request.user, request.node, token_args=[email], reverse_kwargs={'email': email.replace('@', '+')}, secure=request.is_secure()),
461                                                 'user': request.user,
462                                                 'site': current_site,
463                                                 'request': request
464                                         }
465                                         self.send_confirmation_email('Confirm account email change at %s' % current_site.domain, email, self.email_change_confirmation_email, context)
466                                         
467                                         message = "An email has be sent to %s to confirm the email%s." % (email, bool(request.user.email) and " change" or "")
468                                         if not request.user.email:
469                                                 message += " You will need to confirm the email before accessing pages that require a valid account."
470                                                 redirect = ''
471                                 
472                                 form.save()
473                                 
474                                 if redirect != '':
475                                         message += " Here you go!"
476                                 
477                                 messages.add_message(request, messages.SUCCESS, message, fail_silently=True)
478                                 return HttpResponseRedirect(redirect)
479                 else:
480                         form = self.account_form(request.user)
481                 
482                 context = self.get_context()
483                 context.update(extra_context or {})
484                 context.update({
485                         'form': form
486                 })
487                 return self.manage_account_page.render_to_response(request, extra_context=context)
488         
489         def has_valid_account(self, user):
490                 """Returns ``True`` if the ``user`` has a valid account and ``False`` otherwise."""
491                 form = self.account_form(user, {})
492                 form.data = form.initial
493                 return form.is_valid()
494         
495         def account_required(self, view):
496                 """Wraps a view function to allow access only to users with valid accounts and otherwise redirect them to the :meth:`account_view`."""
497                 def inner(request, *args, **kwargs):
498                         if not self.has_valid_account(request.user):
499                                 messages.add_message(request, messages.ERROR, "You need to add some account information before you can access that page.", fail_silently=True)
500                                 if self.manage_account_page:
501                                         self.set_requirement_redirect(request, redirect=request.path)
502                                         redirect = self.reverse('account', node=request.node)
503                                 else:
504                                         redirect = request.node.get_absolute_url()
505                                 return HttpResponseRedirect(redirect)
506                         return view(request, *args, **kwargs)
507                 
508                 inner = self.login_required(inner)
509                 return inner
510         
511         def post_register_confirm_redirect(self, request):
512                 """Automatically redirects users to the :meth:`account_view` after registration."""
513                 if self.manage_account_page:
514                         messages.add_message(request, messages.INFO, 'Welcome! Please fill in some more information.', fail_silently=True)
515                         return HttpResponseRedirect(self.reverse('account', node=request.node))
516                 return super(AccountMultiView, self).post_register_confirm_redirect(request)
517         
518         def email_change_confirm(self, request, extra_context=None, uidb36=None, token=None, email=None, token_generator=email_token_generator):
519                 """
520                 Checks that ``token`` is valid, and if so, changes the user's email.
521                 
522                 :param token_generator: The token generator used to check the ``token``.
523                 
524                 """
525                 assert uidb36 is not None and token is not None and email is not None
526                 
527                 try:
528                         uid_int = base36_to_int(uidb36)
529                 except:
530                         raise Http404
531                 
532                 user = get_object_or_404(User, id=uid_int)
533                 
534                 email = '@'.join(email.rsplit('+', 1))
535                 
536                 if email == user.email:
537                         # Then short-circuit.
538                         raise Http404
539                 
540                 if token_generator.check_token(user, email, token):
541                         user.email = email
542                         user.save()
543                         messages.add_message(request, messages.SUCCESS, 'Email changed successfully.')
544                         if self.manage_account_page:
545                                 redirect = self.reverse('account', node=request.node)
546                         else:
547                                 redirect = request.node.get_absolute_url()
548                         return HttpResponseRedirect(redirect)
549                 
550                 raise Http404
551         
552         class Meta:
553                 abstract = True