diff --git a/README.md b/README.md index e075141d8decffe015559e3060718d428c15cf80..234f11786f0b5404a5c661ef6e557f240076d7fe 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ BASETHEME_BOOTSTRAP_USER_PREFERENCE_MODEL_LOCATION_APP = "test_app_1" BASETHEME_BOOTSTRAP_USER_PREFERENCE_MODEL_NAME = "MyUserPreferences" BASETHEME_BOOTSTRAP_USERNAME_IS_EMAIL = False BASETHEME_BOOTSTRAP_FIRST_LAST_NAME_REQUIRED = False +BASETHEME_BOOTSTRAP_VALIDATE_EMAIL_BEFORE_ACTIVATION = False ################################################################################ ``` diff --git a/README.rst b/README.rst index 8418549e1ad8c952c9343b5a0d6c2d2b058bcabb..3b4ab3d3d21a2cc979f031ac03cafcf306d9516a 100644 --- a/README.rst +++ b/README.rst @@ -52,6 +52,7 @@ Quick start BASETHEME_BOOTSTRAP_USER_PREFERENCE_MODEL_NAME = "MyUserPreferences" BASETHEME_BOOTSTRAP_USERNAME_IS_EMAIL = False BASETHEME_BOOTSTRAP_FIRST_LAST_NAME_REQUIRED = False + BASETHEME_BOOTSTRAP_VALIDATE_EMAIL_BEFORE_ACTIVATION = False ################################################################################ diff --git a/basetheme_bootstrap/default_settings.py b/basetheme_bootstrap/default_settings.py new file mode 100644 index 0000000000000000000000000000000000000000..a6583f05903239995025f6b7d644997ff592ed03 --- /dev/null +++ b/basetheme_bootstrap/default_settings.py @@ -0,0 +1,22 @@ +from django.conf import settings + + +def is_username_is_email(): + try: + return settings.BASETHEME_BOOTSTRAP_USERNAME_IS_EMAIL + except AttributeError: + return False + + +def is_first_last_name_required(): + try: + return settings.BASETHEME_BOOTSTRAP_FIRST_LAST_NAME_REQUIRED + except AttributeError: + return False + + +def is_validating_email(): + try: + return settings.BASETHEME_BOOTSTRAP_VALIDATE_EMAIL_BEFORE_ACTIVATION + except AttributeError: + return False diff --git a/basetheme_bootstrap/forms.py b/basetheme_bootstrap/forms.py index 19e1964ae258f4f89582634b985fd76880d6d8e4..5754f91539b03cb7b3577e6f40714f3c70cd6786 100644 --- a/basetheme_bootstrap/forms.py +++ b/basetheme_bootstrap/forms.py @@ -1,4 +1,4 @@ -from django.conf import settings + from django.contrib.auth import get_user_model, forms as auth_forms from django.db.models import Q from django.forms import ModelForm @@ -6,19 +6,7 @@ from django.urls import reverse from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ - -def is_username_is_email(): - try: - return settings.BASETHEME_BOOTSTRAP_USERNAME_IS_EMAIL - except AttributeError: - return False - - -def is_first_last_name_required(): - try: - return settings.BASETHEME_BOOTSTRAP_FIRST_LAST_NAME_REQUIRED - except AttributeError: - return False +from basetheme_bootstrap.default_settings import is_username_is_email, is_first_last_name_required class CleanUsernameAndSuggestReset: diff --git a/basetheme_bootstrap/tests.py b/basetheme_bootstrap/tests.py index 78a60049a695b8cd107fd649f96accf9a99b1b86..f1752c0b7c184ef5bca024c001f3fe94b46ec386 100644 --- a/basetheme_bootstrap/tests.py +++ b/basetheme_bootstrap/tests.py @@ -1,14 +1,16 @@ import os +import re from django.conf import settings from django.contrib.auth import get_user_model -from django.contrib.auth.models import AnonymousUser +from django.contrib.auth.models import AnonymousUser, Group +from django.core import mail from django.core.cache import cache from django.core.exceptions import ValidationError from django.test import TestCase, RequestFactory, override_settings from django.urls import reverse -from basetheme_bootstrap import user_preferences +from basetheme_bootstrap import user_preferences, tokens from basetheme_bootstrap.user_preferences import get_user_preferences_for_user @@ -138,6 +140,99 @@ class SignUpWithFirstLastNameRequiredTests(TestCase): self.assertEqual(response.status_code, 200) +@override_settings( + BASETHEME_BOOTSTRAP_VALIDATE_EMAIL_BEFORE_ACTIVATION=True, + PASSWORD_RESET_TIMEOUT_DAYS=1, +) +class SignUpWithValidationTests(TestCase): + + def setUp(self): + cache.clear() + get_user_model().objects.create( + username="root", + ) + self.data = { + 'username': "userAAA", + 'email': "userAAA@mp.com", + 'password1': "user@mp.comuser@mp.comuser@mp.comuser@mp.com", + 'password2': "user@mp.comuser@mp.comuser@mp.comuser@mp.com", + 'first_name': "user" + } + + def test_sign_up_form_view(self): + response = self.client.post(reverse('basetheme_bootstrap:signup'), self.data) + self.assertEqual(response.status_code, 200) + + user = get_user_model().objects.last() + self.assertFalse(user.is_active) + self.assertIn(Group.objects.get(name="PendingAccountUser"), user.groups.all()) + + self.assertEqual(len(mail.outbox), 1) + activate_link_example = reverse('basetheme_bootstrap:activate', args=["AAA", "AAA-AAA"]) + activate_link_example = activate_link_example.replace("AAA", "([a-zA-Z0-9]+)") + m = re.findall(activate_link_example, mail.outbox[0].body) + self.assertEqual(len(m), 1) + self.client.post(reverse('basetheme_bootstrap:activate', args=[m[0][0], m[0][1] + "-" + m[0][2]])) + user = get_user_model().objects.last() + self.assertTrue(user.is_active) + self.assertNotIn(Group.objects.get(name="PendingAccountUser"), user.groups.all()) + + ## test an account cannot be re-activated with the link: + user.is_active=False + user.save() + self.client.post(reverse('basetheme_bootstrap:activate', args=[m[0][0], m[0][1] + "-" + m[0][2]])) + self.assertFalse(get_user_model().objects.last().is_active) + + def test_sign_up_with_user_pending_resend_email(self): + user_count = get_user_model().objects.count() + response = self.client.post(reverse('basetheme_bootstrap:signup'), self.data) + self.assertEqual(get_user_model().objects.count(), user_count + 1) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(mail.outbox), 1) + + response = self.client.post(reverse('basetheme_bootstrap:signup'), self.data) + self.assertEqual(get_user_model().objects.count(), user_count + 1) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(mail.outbox), 2) + activate_link_example = reverse('basetheme_bootstrap:activate', args=["AAA", "AAA-AAA"]) + activate_link_example = activate_link_example.replace("AAA", "([a-zA-Z0-9]+)") + m = re.findall(activate_link_example, mail.outbox[-1].body) + self.assertEqual(len(m), 1) + + def test_activate_too_late_with_user_pending_resend_email(self): + actual_account_activation_token = tokens.account_activation_token + + class MockedTokenGenerator(tokens.TokenGenerator): + def _today(self): + from datetime import date, timedelta + # Used for mocking in tests + return date.today() - timedelta(days=2) + + tokens.account_activation_token = MockedTokenGenerator() + + user_count = get_user_model().objects.count() + response = self.client.post(reverse('basetheme_bootstrap:signup'), self.data) + self.assertEqual(get_user_model().objects.count(), user_count + 1) + self.assertEqual(response.status_code, 200) + self.assertFalse(get_user_model().objects.last().is_active) + + tokens.account_activation_token = actual_account_activation_token + + self.assertEqual(len(mail.outbox), 1) + activate_link_example = reverse('basetheme_bootstrap:activate', args=["AAA", "AAA-AAA"]) + activate_link_example = activate_link_example.replace("AAA", "([a-zA-Z0-9]+)") + m = re.findall(activate_link_example, mail.outbox[0].body) + self.assertEqual(len(m), 1) + self.client.post(reverse('basetheme_bootstrap:activate', args=[m[0][0], m[0][1] + "-" + m[0][2]])) + + self.assertFalse(get_user_model().objects.last().is_active) + self.assertEqual(len(mail.outbox), 2) + activate_link_example = reverse('basetheme_bootstrap:activate', args=["AAA", "AAA-AAA"]) + activate_link_example = activate_link_example.replace("AAA", "([a-zA-Z0-9]+)") + m = re.findall(activate_link_example, mail.outbox[1].body) + self.assertEqual(len(m), 1) + + class TestWithTemplatesInPlace(SignUpTests): def setUp(self): diff --git a/basetheme_bootstrap/tokens.py b/basetheme_bootstrap/tokens.py new file mode 100644 index 0000000000000000000000000000000000000000..d1501a6857ad17623de9e2b269aafb9e87029982 --- /dev/null +++ b/basetheme_bootstrap/tokens.py @@ -0,0 +1,13 @@ +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.utils import six + + +class TokenGenerator(PasswordResetTokenGenerator): + def _make_hash_value(self, user, timestamp): + return ( + six.text_type(user.pk) + six.text_type(timestamp) + + six.text_type(user.is_active) + ) + + +account_activation_token = TokenGenerator() diff --git a/basetheme_bootstrap/urls.py b/basetheme_bootstrap/urls.py index af4d5eb3e92282913918950e8b0c4a9ba95861d8..a5658e27f297fe43cbae7023886e8e3c45af2564 100644 --- a/basetheme_bootstrap/urls.py +++ b/basetheme_bootstrap/urls.py @@ -19,6 +19,8 @@ urlpatterns = [ url(r'^accounts/signup/$', views.signup, name='signup'), url(r'^accounts/edit/$', views.user_update, name='user-update'), url(r'^accounts/remove/$', views.user_delete, name='user-delete'), + url(r'^accounts/activate/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', + views.activate, name='activate'), ################################################################################ # Lost password ################################################################################ diff --git a/basetheme_bootstrap/views.py b/basetheme_bootstrap/views.py index a0fae35ca8d266cff7b64833604985fe238909e7..0ca369fbeab5c94cfc25328d84f6517dd504d40c 100644 --- a/basetheme_bootstrap/views.py +++ b/basetheme_bootstrap/views.py @@ -6,15 +6,21 @@ from django.contrib import messages from django.contrib.auth import update_session_auth_hash, authenticate, login, get_user_model from django.contrib.auth.decorators import login_required from django.contrib.auth.forms import PasswordChangeForm +from django.contrib.auth.models import Group from django.core.mail import send_mail from django.db.models import ProtectedError from django.forms import widgets from django.http import HttpResponseForbidden from django.shortcuts import render, redirect from django.template import TemplateDoesNotExist +from django.urls import reverse +from django.utils.encoding import force_bytes, force_text +from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode from django.utils.translation import ugettext +from basetheme_bootstrap import tokens from basetheme_bootstrap import user_preferences +from basetheme_bootstrap.default_settings import is_validating_email from basetheme_bootstrap.forms import UserCreationFormWithMore, \ MyUserChangeForm, UserDeleteForm @@ -53,35 +59,38 @@ def signup(request): if request.method == 'POST': form = UserCreationFormWithMore(request.POST) if form.is_valid(): + auto_active = not is_validating_email() user = form.save() if get_user_model().objects.filter(pk__gt=1).count() == 0: user.is_superuser = True user.is_staff = True - user.save() + user.is_active = True + Group.objects.get_or_create(name="PendingAccountUser") + else: + if not auto_active: + g, created = Group.objects.get_or_create(name="PendingAccountUser") + user.groups.add(g) + user.is_active = auto_active + user.save() + + send_account_created(request, user, auto_active=auto_active) + + if not auto_active: + return account_is_pending_view(request, email=request.POST['email']) + username = user.username raw_password = form.cleaned_data.get('password1') user = authenticate(username=username, password=raw_password) request.user = user - try: - send_mail( - subject=ugettext('Account successfully created'), - message=ugettext( - 'Dear %(first_name)s %(last_name)s\n\n' - 'Your account have successfully been created on %(joined)s.\n\nBest regards') % dict( - first_name=request.user.first_name, - last_name=request.user.last_name, - joined=str(request.user.date_joined), - ), - from_email=settings.DEFAULT_FROM_EMAIL, - recipient_list=[request.user.email], - fail_silently=False, - ) - except Exception as e: - logging.exception("Sending email to user %i failed" % user.pk) - login(request, user) return redirect('home') + else: + user = get_user_model().objects.filter(groups__name="PendingAccountUser", + email=request.POST['email']).first() + if user is not None: + send_account_created(request, user, auto_active=not is_validating_email()) + return account_is_pending_view(request, email=request.POST['email']) else: if not request.user.is_anonymous: return HttpResponseForbidden() @@ -89,6 +98,57 @@ def signup(request): return render(request, 'registration/signup.html', {'form': form}) +def account_is_pending_view(request, email): + return render(request, 'basetheme_bootstrap/simple_message_page.html', { + 'page_title': ugettext('Account activation pending'), + 'message': ugettext('An email was sent with a link to validate your account, ' + 'please click on the link to enable your account.'), + 'sub_message': ugettext('The email has been addressed to %s.') % email, + }) + + +def send_account_created(request, user, auto_active=False): + try: + activation_link = request.scheme + "://" + request.get_host() + activation_link += reverse('basetheme_bootstrap:activate', kwargs={ + 'uidb64': urlsafe_base64_encode(force_bytes(user.pk)).decode(), + 'token': tokens.account_activation_token.make_token(user) + }) + if auto_active: + message = ugettext( + 'Dear %(first_name)s %(last_name)s\n\n' + 'Your account have successfully been created on %(joined)s.' + '\n\n' + 'Best regards') % dict( + first_name=user.first_name, + last_name=user.last_name, + joined=str(user.date_joined), + ) + else: + message = ugettext( + 'Dear %(first_name)s %(last_name)s\n\n' + 'Your account have successfully been created on %(joined)s.' + '\n\n' + 'Please click on the link to confirm your registration\n' + '%(activation_link)s' + '\n\n' + 'Best regards') % dict( + first_name=user.first_name, + last_name=user.last_name, + joined=str(user.date_joined), + activation_link=activation_link, + ) + send_mail( + subject=ugettext('Account successfully created'), + message=message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + fail_silently=False, + ) + except Exception as e: + logging.exception("Sending email to user %i failed" % user.pk) + + def user_update(request): if request.method == 'POST': form = MyUserChangeForm(instance=request.user, data=request.POST) @@ -165,3 +225,31 @@ def account_detail(request): 'form_prefs': form_prefs, 'btn_classes': 'pull-right float-right' }) + + +def activate(request, uidb64, token): + try: + uid = force_text(urlsafe_base64_decode(uidb64)) + user = get_user_model().objects.get(pk=uid) + except(TypeError, ValueError, OverflowError, get_user_model().DoesNotExist): + user = None + if user is not None and user.groups.filter(name="PendingAccountUser").exists(): + if tokens.account_activation_token.check_token(user, token): + user.is_active = True + user.groups.remove(user.groups.get(name="PendingAccountUser")) + user.save() + login(request, user) + # return redirect('home') + # return HttpResponse('Thank you for your email confirmation. Now you can login your account.') + return render(request, 'basetheme_bootstrap/simple_message_page.html', { + 'page_title': ugettext('Account activated'), + 'message': ugettext('Thank you for your email confirmation, you account have been activated ' + 'and you are now logged in.'), + }) + else: + send_account_created(request, user) + return account_is_pending_view(request, email=user.email) + else: + return render(request, 'basetheme_bootstrap/simple_message_page.html', { + 'page_title': ugettext('Activation link is invalid!'), + }) diff --git a/setup.py b/setup.py index cbad581badf5325fc03a0139662bdcc04cbff788..c933e1fc9247344922958562fdd399f1dc78390f 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ readme = open('README.rst').read() setup( name='django-basetheme-bootstrap', - version='0.2.29', + version='0.2.30', description='Django Basetheme Bootstrap', long_description=readme, author='Bryan Brancotte', diff --git a/tests/settings.py b/tests/settings.py index 38b8df31420626ea8ccd9fe996fa788503c6d5d9..55ceb8ef2c6905f2e01e197aee9a66a202c2b734 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -138,6 +138,7 @@ BASETHEME_BOOTSTRAP_USER_PREFERENCE_MODEL_LOCATION_APP = "test_app_1" BASETHEME_BOOTSTRAP_USER_PREFERENCE_MODEL_NAME = "MyUserPreferences" # BASETHEME_BOOTSTRAP_USERNAME_IS_EMAIL = True # default is False # BASETHEME_BOOTSTRAP_FIRST_LAST_NAME_REQUIRED = True # default is False +# BASETHEME_BOOTSTRAP_VALIDATE_EMAIL_BEFORE_ACTIVATION = True # default is False ################################################################################ # Various debug stuff