Merge branch 'efficient_attributes' into attribute_access
[philo.git] / philo / contrib / waldo / models.py
1 import urlparse
2
3 from django import forms
4 from django.conf.urls.defaults import url, patterns, include
5 from django.contrib import messages
6 from django.contrib.auth import authenticate, login, views as auth_views
7 from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm, PasswordChangeForm
8 from django.contrib.auth.models import User
9 from django.contrib.auth.tokens import default_token_generator as password_token_generator
10 from django.contrib.sites.models import Site
11 from django.core.mail import EmailMultiAlternatives, send_mail
12 from django.db import models
13 from django.http import Http404, HttpResponseRedirect
14 from django.shortcuts import render_to_response, get_object_or_404
15 from django.template.defaultfilters import striptags
16 from django.utils.http import int_to_base36, base36_to_int
17 from django.utils.translation import ugettext as _
18 from django.views.decorators.cache import never_cache
19 from django.views.decorators.csrf import csrf_protect
20
21 from philo.models import MultiView, Page
22 from philo.contrib.waldo.forms import WaldoAuthenticationForm, RegistrationForm, UserAccountForm
23 from philo.contrib.waldo.tokens import registration_token_generator, email_token_generator
24
25
26 class LoginMultiView(MultiView):
27         """
28         Handles exclusively methods and views related to logging users in and out.
29         """
30         login_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_login_related')
31         login_form = WaldoAuthenticationForm
32         
33         @property
34         def urlpatterns(self):
35                 return patterns('',
36                         url(r'^login$', self.login, name='login'),
37                         url(r'^logout$', self.logout, name='logout'),
38                 )
39         
40         def set_requirement_redirect(self, request, redirect=None):
41                 "Figure out where someone should end up after landing on a `requirement` page like the login page."
42                 if redirect is not None:
43                         pass
44                 elif 'requirement_redirect' in request.session:
45                         return
46                 else:
47                         referrer = request.META.get('HTTP_REFERER', None)
48                 
49                         if referrer is not None:
50                                 referrer = urlparse.urlparse(referrer)
51                                 host = referrer[1]
52                                 if host != request.get_host():
53                                         referrer = None
54                                 else:
55                                         redirect = '%s?%s' % (referrer[2], referrer[4])
56                 
57                         path = request.get_full_path()
58                         if referrer is None or redirect == path:
59                                 # Default to the index page if we can't find a referrer or
60                                 # if we'd otherwise redirect to where we already are.
61                                 redirect = request.node.get_absolute_url()
62                 
63                 request.session['requirement_redirect'] = redirect
64         
65         def get_requirement_redirect(self, request, default=None):
66                 redirect = request.session.pop('requirement_redirect', None)
67                 # Security checks a la django.contrib.auth.views.login
68                 if not redirect or ' ' in redirect:
69                         redirect = default
70                 else:
71                         netloc = urlparse.urlparse(redirect)[1]
72                         if netloc and netloc != request.get_host():
73                                 redirect = default
74                 if redirect is None:
75                         redirect = request.node.get_absolute_url()
76                 return redirect
77         
78         @never_cache
79         def login(self, request, extra_context=None):
80                 """
81                 Displays the login form for the given HttpRequest.
82                 """
83                 self.set_requirement_redirect(request)
84                 
85                 # Redirect already-authenticated users to the index page.
86                 if request.user.is_authenticated():
87                         messages.add_message(request, messages.INFO, "You are already authenticated. Please log out if you wish to log in as a different user.")
88                         return HttpResponseRedirect(self.get_requirement_redirect(request))
89                 
90                 if request.method == 'POST':
91                         form = self.login_form(request=request, data=request.POST)
92                         if form.is_valid():
93                                 redirect = self.get_requirement_redirect(request)
94                                 login(request, form.get_user())
95                                 
96                                 if request.session.test_cookie_worked():
97                                         request.session.delete_test_cookie()
98                                 
99                                 return HttpResponseRedirect(redirect)
100                 else:
101                         form = self.login_form()
102                 
103                 request.session.set_test_cookie()
104                 
105                 context = self.get_context()
106                 context.update(extra_context or {})
107                 context.update({
108                         'form': form
109                 })
110                 return self.login_page.render_to_response(request, extra_context=context)
111         
112         @never_cache
113         def logout(self, request, extra_context=None):
114                 return auth_views.logout(request, request.META.get('HTTP_REFERER', request.node.get_absolute_url()))
115         
116         def login_required(self, view):
117                 def inner(request, *args, **kwargs):
118                         if not request.user.is_authenticated():
119                                 self.set_requirement_redirect(request, redirect=request.path)
120                                 if request.POST:
121                                         messages.add_message(request, messages.ERROR, "Please log in again, because your session has expired.")
122                                 return HttpResponseRedirect(self.reverse('login', node=request.node))
123                         return view(request, *args, **kwargs)
124                 
125                 return inner
126         
127         class Meta:
128                 abstract = True
129
130
131 class PasswordMultiView(LoginMultiView):
132         "Adds on views for password-related functions."
133         password_reset_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_reset_related', blank=True, null=True)
134         password_reset_confirmation_email = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_reset_confirmation_email_related', blank=True, null=True)
135         password_set_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_set_related', blank=True, null=True)
136         password_change_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_change_related', blank=True, null=True)
137         
138         password_change_form = PasswordChangeForm
139         password_set_form = SetPasswordForm
140         password_reset_form = PasswordResetForm
141         
142         @property
143         def urlpatterns(self):
144                 urlpatterns = super(PasswordMultiView, self).urlpatterns
145                 
146                 if self.password_reset_page and self.password_reset_confirmation_email and self.password_set_page:
147                         urlpatterns += patterns('',
148                                 url(r'^password/reset$', csrf_protect(self.password_reset), name='password_reset'),
149                                 url(r'^password/reset/(?P<uidb36>\w+)/(?P<token>[^/]+)$', self.password_reset_confirm, name='password_reset_confirm'),
150                         )
151                 
152                 if self.password_change_page:
153                         urlpatterns += patterns('',
154                                 url(r'^password/change$', csrf_protect(self.login_required(self.password_change)), name='password_change'),
155                         )
156                 return urlpatterns
157         
158         def make_confirmation_link(self, confirmation_view, token_generator, user, node, token_args=None, reverse_kwargs=None, secure=False):
159                 token = token_generator.make_token(user, *(token_args or []))
160                 kwargs = {
161                         'uidb36': int_to_base36(user.id),
162                         'token': token
163                 }
164                 kwargs.update(reverse_kwargs or {})
165                 return node.construct_url(subpath=self.reverse(confirmation_view, kwargs=kwargs), with_domain=True, secure=secure)
166         
167         def send_confirmation_email(self, subject, email, page, extra_context):
168                 text_content = page.render_to_string(extra_context=extra_context)
169                 from_email = 'noreply@%s' % Site.objects.get_current().domain
170                 
171                 if page.template.mimetype == 'text/html':
172                         msg = EmailMultiAlternatives(subject, striptags(text_content), from_email, [email])
173                         msg.attach_alternative(text_content, 'text/html')
174                         msg.send()
175                 else:
176                         send_mail(subject, text_content, from_email, [email])
177         
178         def password_reset(self, request, extra_context=None, token_generator=password_token_generator):
179                 if request.user.is_authenticated():
180                         return HttpResponseRedirect(request.node.get_absolute_url())
181                 
182                 if request.method == 'POST':
183                         form = self.password_reset_form(request.POST)
184                         if form.is_valid():
185                                 current_site = Site.objects.get_current()
186                                 for user in form.users_cache:
187                                         context = {
188                                                 'link': self.make_confirmation_link('password_reset_confirm', token_generator, user, request.node, secure=request.is_secure()),
189                                                 'user': user,
190                                                 'site': current_site,
191                                                 'request': request,
192                                                 
193                                                 # Deprecated... leave in for backwards-compatibility
194                                                 'username': user.username
195                                         }
196                                         self.send_confirmation_email('Confirm password reset for account at %s' % current_site.domain, user.email, self.password_reset_confirmation_email, context)
197                                         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)
198                                 return HttpResponseRedirect('')
199                 else:
200                         form = self.password_reset_form()
201                 
202                 context = self.get_context()
203                 context.update(extra_context or {})
204                 context.update({
205                         'form': form
206                 })
207                 return self.password_reset_page.render_to_response(request, extra_context=context)
208         
209         def password_reset_confirm(self, request, extra_context=None, uidb36=None, token=None, token_generator=password_token_generator):
210                 """
211                 Checks that a given hash in a password reset link is valid. If so,
212                 displays the password set form.
213                 """
214                 assert uidb36 is not None and token is not None
215                 try:
216                         uid_int = base36_to_int(uidb36)
217                 except:
218                         raise Http404
219                 
220                 user = get_object_or_404(User, id=uid_int)
221                 
222                 if token_generator.check_token(user, token):
223                         if request.method == 'POST':
224                                 form = self.password_set_form(user, request.POST)
225                                 
226                                 if form.is_valid():
227                                         form.save()
228                                         messages.add_message(request, messages.SUCCESS, "Password reset successful.")
229                                         return HttpResponseRedirect(self.reverse('login', node=request.node))
230                         else:
231                                 form = self.password_set_form(user)
232                         
233                         context = self.get_context()
234                         context.update(extra_context or {})
235                         context.update({
236                                 'form': form
237                         })
238                         return self.password_set_page.render_to_response(request, extra_context=context)
239                 
240                 raise Http404
241         
242         def password_change(self, request, extra_context=None):
243                 if request.method == 'POST':
244                         form = self.password_change_form(request.user, request.POST)
245                         if form.is_valid():
246                                 form.save()
247                                 messages.add_message(request, messages.SUCCESS, 'Password changed successfully.', fail_silently=True)
248                                 return HttpResponseRedirect('')
249                 else:
250                         form = self.password_change_form(request.user)
251                 
252                 context = self.get_context()
253                 context.update(extra_context or {})
254                 context.update({
255                         'form': form
256                 })
257                 return self.password_change_page.render_to_response(request, extra_context=context)
258         
259         class Meta:
260                 abstract = True
261
262
263 class RegistrationMultiView(PasswordMultiView):
264         """Adds on the pages necessary for letting new users register."""
265         register_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_register_related', blank=True, null=True)
266         register_confirmation_email = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_register_confirmation_email_related', blank=True, null=True)
267         registration_form = RegistrationForm
268         
269         @property
270         def urlpatterns(self):
271                 urlpatterns = super(RegistrationMultiView, self).urlpatterns
272                 if self.register_page and self.register_confirmation_email:
273                         urlpatterns += patterns('',
274                                 url(r'^register$', csrf_protect(self.register), name='register'),
275                                 url(r'^register/(?P<uidb36>\w+)/(?P<token>[^/]+)$', self.register_confirm, name='register_confirm')
276                         )
277                 return urlpatterns
278         
279         def register(self, request, extra_context=None, token_generator=registration_token_generator):
280                 if request.user.is_authenticated():
281                         return HttpResponseRedirect(request.node.get_absolute_url())
282                 
283                 if request.method == 'POST':
284                         form = self.registration_form(request.POST)
285                         if form.is_valid():
286                                 user = form.save()
287                                 current_site = Site.objects.get_current()
288                                 context = {
289                                         'link': self.make_confirmation_link('register_confirm', token_generator, user, request.node, secure=request.is_secure()),
290                                         'user': user,
291                                         'site': current_site,
292                                         'request': request
293                                 }
294                                 self.send_confirmation_email('Confirm account creation at %s' % current_site.name, user.email, self.register_confirmation_email, context)
295                                 messages.add_message(request, messages.SUCCESS, 'An email has been sent to %s with details on activating your account.' % user.email, fail_silently=True)
296                                 return HttpResponseRedirect(request.node.get_absolute_url())
297                 else:
298                         form = self.registration_form()
299                 
300                 context = self.get_context()
301                 context.update(extra_context or {})
302                 context.update({
303                         'form': form
304                 })
305                 return self.register_page.render_to_response(request, extra_context=context)
306         
307         def register_confirm(self, request, extra_context=None, uidb36=None, token=None, token_generator=registration_token_generator):
308                 """
309                 Checks that a given hash in a registration link is valid and activates
310                 the given account. If so, log them in and redirect to
311                 self.post_register_confirm_redirect.
312                 """
313                 assert uidb36 is not None and token is not None
314                 try:
315                         uid_int = base36_to_int(uidb36)
316                 except:
317                         raise Http404
318                 
319                 user = get_object_or_404(User, id=uid_int)
320                 if token_generator.check_token(user, token):
321                         user.is_active = True
322                         true_password = user.password
323                         temp_password = token_generator.make_token(user)
324                         try:
325                                 user.set_password(temp_password)
326                                 user.save()
327                                 authenticated_user = authenticate(username=user.username, password=temp_password)
328                                 login(request, authenticated_user)
329                         finally:
330                                 # if anything goes wrong, do our best make sure that the true password is restored.
331                                 user.password = true_password
332                                 user.save()
333                         return self.post_register_confirm_redirect(request)
334                 
335                 raise Http404
336         
337         def post_register_confirm_redirect(self, request):
338                 return HttpResponseRedirect(request.node.get_absolute_url())
339         
340         class Meta:
341                 abstract = True
342
343
344 class AccountMultiView(RegistrationMultiView):
345         """
346         By default, the `account` consists of the first_name, last_name, and email fields
347         of the User model. Using a different account model is as simple as writing a form that
348         accepts a User instance as the first argument.
349         """
350         manage_account_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_manage_account_related', blank=True, null=True)
351         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.")
352         
353         account_form = UserAccountForm
354         
355         @property
356         def urlpatterns(self):
357                 urlpatterns = super(AccountMultiView, self).urlpatterns
358                 if self.manage_account_page:
359                         urlpatterns += patterns('',
360                                 url(r'^account$', self.login_required(self.account_view), name='account'),
361                         )
362                 if self.email_change_confirmation_email:
363                         urlpatterns += patterns('',
364                                 url(r'^account/email/(?P<uidb36>\w+)/(?P<email>[\w.]+[+][\w.]+)/(?P<token>[^/]+)$', self.email_change_confirm, name='email_change_confirm')
365                         )
366                 return urlpatterns
367         
368         def account_view(self, request, extra_context=None, token_generator=email_token_generator, *args, **kwargs):
369                 if request.method == 'POST':
370                         form = self.account_form(request.user, request.POST, request.FILES)
371                         
372                         if form.is_valid():
373                                 message = "Account information saved."
374                                 redirect = self.get_requirement_redirect(request, default='')
375                                 if 'email' in form.changed_data and self.email_change_confirmation_email:
376                                         # ModelForms modify their instances in-place during
377                                         # validation, so reset the instance's email to its
378                                         # previous value here, then remove the new value
379                                         # from cleaned_data. We only do this if an email
380                                         # change confirmation email is available.
381                                         request.user.email = form.initial['email']
382                                         
383                                         email = form.cleaned_data.pop('email')
384                                         
385                                         current_site = Site.objects.get_current()
386                                         
387                                         context = {
388                                                 '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()),
389                                                 'user': request.user,
390                                                 'site': current_site,
391                                                 'request': request
392                                         }
393                                         self.send_confirmation_email('Confirm account email change at %s' % current_site.domain, email, self.email_change_confirmation_email, context)
394                                         
395                                         message = "An email has be sent to %s to confirm the email%s." % (email, bool(request.user.email) and " change" or "")
396                                         if not request.user.email:
397                                                 message += " You will need to confirm the email before accessing pages that require a valid account."
398                                                 redirect = ''
399                                 
400                                 form.save()
401                                 
402                                 if redirect != '':
403                                         message += " Here you go!"
404                                 
405                                 messages.add_message(request, messages.SUCCESS, message, fail_silently=True)
406                                 return HttpResponseRedirect(redirect)
407                 else:
408                         form = self.account_form(request.user)
409                 
410                 context = self.get_context()
411                 context.update(extra_context or {})
412                 context.update({
413                         'form': form
414                 })
415                 return self.manage_account_page.render_to_response(request, extra_context=context)
416         
417         def has_valid_account(self, user):
418                 form = self.account_form(user, {})
419                 form.data = form.initial
420                 return form.is_valid()
421         
422         def account_required(self, view):
423                 def inner(request, *args, **kwargs):
424                         if not self.has_valid_account(request.user):
425                                 messages.add_message(request, messages.ERROR, "You need to add some account information before you can access that page.", fail_silently=True)
426                                 if self.manage_account_page:
427                                         self.set_requirement_redirect(request, redirect=request.path)
428                                         redirect = self.reverse('account', node=request.node)
429                                 else:
430                                         redirect = node.get_absolute_url()
431                                 return HttpResponseRedirect(redirect)
432                         return view(request, *args, **kwargs)
433                 
434                 inner = self.login_required(inner)
435                 return inner
436         
437         def post_register_confirm_redirect(self, request):
438                 if self.manage_account_page:
439                         messages.add_message(request, messages.INFO, 'Welcome! Please fill in some more information.', fail_silently=True)
440                         return HttpResponseRedirect(self.reverse('account', node=request.node))
441                 return super(AccountMultiView, self).post_register_confirm_redirect(request)
442         
443         def email_change_confirm(self, request, extra_context=None, uidb36=None, token=None, email=None, token_generator=email_token_generator):
444                 """
445                 Checks that a given hash in an email change link is valid. If so, changes the email and redirects to the account page.
446                 """
447                 assert uidb36 is not None and token is not None and email is not None
448                 
449                 try:
450                         uid_int = base36_to_int(uidb36)
451                 except:
452                         raise Http404
453                 
454                 user = get_object_or_404(User, id=uid_int)
455                 
456                 email = '@'.join(email.rsplit('+', 1))
457                 
458                 if email == user.email:
459                         # Then short-circuit.
460                         raise Http404
461                 
462                 if token_generator.check_token(user, email, token):
463                         user.email = email
464                         user.save()
465                         messages.add_message(request, messages.SUCCESS, 'Email changed successfully.')
466                         if self.manage_account_page:
467                                 redirect = self.reverse('account', node=request.node)
468                         else:
469                                 redirect = request.node.get_absolute_url()
470                         return HttpResponseRedirect(redirect)
471                 
472                 raise Http404
473         
474         class Meta:
475                 abstract = True