diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 2776bd50..42482bf4 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -32,6 +32,8 @@ What you get * Password reset links that expire in two days (configurable). +* Optionally invoke a captcha in the password reset page if django-simple-captcha (0.5.1 or higher) is installed and configured. + What you can do --------------- diff --git a/password_reset/__init__.py b/password_reset/__init__.py index ef72cc0f..4ca39e7c 100644 --- a/password_reset/__init__.py +++ b/password_reset/__init__.py @@ -1 +1 @@ -__version__ = '0.8.1' +__version__ = '0.8.2' diff --git a/password_reset/forms.py b/password_reset/forms.py index 4f8b4ec5..4d7e5d7f 100644 --- a/password_reset/forms.py +++ b/password_reset/forms.py @@ -4,15 +4,29 @@ from django.utils.translation import ugettext_lazy as _ from django.conf import settings + +try: + if 'captcha' in settings.INSTALLED_APPS: + from captcha.fields import CaptchaField # uses django-simple-captcha +except ImportError: + CaptchaField = None + pass + from .utils import get_user_model +error_messages = { + 'not_found': _("Sorry, this user doesn't exist."), + 'password_mismatch': _("The two passwords didn't match."), +} + class PasswordRecoveryForm(forms.Form): username_or_email = forms.CharField() - - error_messages = { - 'not_found': _("Sorry, this user doesn't exist."), - } + try: + if CaptchaField and settings.CAPTCHA_CHALLENGE_FUNCT: # defined + captcha = CaptchaField(label=_('Captcha')) # Optional Captcha + except: + pass def __init__(self, *args, **kwargs): self.case_sensitive = kwargs.pop('case_sensitive', True) @@ -63,7 +77,7 @@ def get_user_by_username(self, username): try: user = User._default_manager.get(**{key: username}) except User.DoesNotExist: - raise forms.ValidationError(self.error_messages['not_found'], + raise forms.ValidationError(error_messages['not_found'], code='not_found') return user @@ -74,20 +88,24 @@ def get_user_by_email(self, email): try: user = User._default_manager.get(**{key: email}) except User.DoesNotExist: - raise forms.ValidationError(self.error_messages['not_found'], + raise forms.ValidationError(error_messages['not_found'], code='not_found') return user def get_user_by_both(self, username): key = '__%sexact' key = key % '' if self.case_sensitive else key % 'i' - f = lambda field: Q(**{field + key: username}) + # f = lambda field: Q(**{field + key: username}) + + def f(field): # to satisfy lint in Travis auto build on Github + return Q(**{field + key: username}) + filters = f('username') | f('email') User = get_user_model() try: user = User._default_manager.get(filters) except User.DoesNotExist: - raise forms.ValidationError(self.error_messages['not_found'], + raise forms.ValidationError(error_messages['not_found'], code='not_found') except User.MultipleObjectsReturned: raise forms.ValidationError(_("Unable to find user.")) @@ -105,10 +123,6 @@ class PasswordResetForm(forms.Form): widget=forms.PasswordInput, ) - error_messages = { - 'password_mismatch': _("The two passwords didn't match."), - } - def __init__(self, *args, **kwargs): self.user = kwargs.pop('user') super(PasswordResetForm, self).__init__(*args, **kwargs) @@ -118,7 +132,7 @@ def clean_password2(self): password2 = self.cleaned_data['password2'] if not password1 == password2: raise forms.ValidationError( - self.error_messages['password_mismatch'], + error_messages['password_mismatch'], code='password_mismatch') return password2 diff --git a/password_reset/locale/zh b/password_reset/locale/zh new file mode 120000 index 00000000..80d1c227 --- /dev/null +++ b/password_reset/locale/zh @@ -0,0 +1 @@ +zh-hans \ No newline at end of file diff --git a/password_reset/locale/zh-hans/LC_MESSAGES/django.mo b/password_reset/locale/zh-hans/LC_MESSAGES/django.mo new file mode 100644 index 00000000..eb97a384 Binary files /dev/null and b/password_reset/locale/zh-hans/LC_MESSAGES/django.mo differ diff --git a/password_reset/locale/zh/LC_MESSAGES/django.po b/password_reset/locale/zh-hans/LC_MESSAGES/django.po similarity index 60% rename from password_reset/locale/zh/LC_MESSAGES/django.po rename to password_reset/locale/zh-hans/LC_MESSAGES/django.po index 12fa1c0a..6fdbb8df 100644 --- a/password_reset/locale/zh/LC_MESSAGES/django.po +++ b/password_reset/locale/zh-hans/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2014-01-23 10:01+0800\n" +"POT-Creation-Date: 2016-02-05 16:48+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,35 +17,43 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: forms.py:26 +#: forms.py:14 +msgid "Captcha" +msgstr "认证码" + +#: forms.py:17 +msgid "Sorry, this user doesn't exist." +msgstr "抱歉,用户名不存在." + +#: forms.py:34 msgid "Username" msgstr "用户名" -#: forms.py:27 +#: forms.py:35 msgid "Email" msgstr "邮箱" -#: forms.py:28 +#: forms.py:36 msgid "Username or Email" msgstr "用户名或者邮箱" -#: forms.py:48 forms.py:58 forms.py:70 -msgid "Sorry, this user doesn't exist." -msgstr "抱歉,用户名不存在." +#: forms.py:58 +msgid "Sorry, inactive users can't recover their password." +msgstr "非常抱歉,非活跃用户无法重置密码。" -#: forms.py:72 +#: forms.py:96 msgid "Unable to find user." msgstr "找不到指定用户" -#: forms.py:78 +#: forms.py:103 msgid "New password" msgstr "新密码" -#: forms.py:82 +#: forms.py:107 msgid "New password (confirm)" -msgstr "新密码(确认)" +msgstr "新密码(确认)" -#: forms.py:94 +#: forms.py:112 msgid "The two passwords didn't match." msgstr "两次输入的密码不一致" @@ -57,19 +65,19 @@ msgstr "设置新密码" msgid "" "Your password has successfully been reset. You can use it right now on the " "login page." -msgstr "您的密码已经重设成功,现在可以登录了." +msgstr "您的密码已经重设成功,现在可以登录了。" #: templates/password_reset/recovery_email.txt:1 #, python-format msgid "Dear %(username)s," -msgstr "您好 %(username)s," +msgstr "%(username)s,您好:" #: templates/password_reset/recovery_email.txt:3 #, python-format msgid "" "You -- or someone pretending to be you -- has requested a password reset on " "%(domain)s." -msgstr "您或者其它的人在 %(domain)s 申请了密码重置." +msgstr "您或某人在 %(domain)s 申请了密码重置。" #: templates/password_reset/recovery_email.txt:5 msgid "You can set your new password by following this link:" @@ -79,18 +87,30 @@ msgstr "您可以通过以下链接重置密码:" msgid "" "If you don't want to reset your password, simply ignore this email and it " "will stay unchanged." -msgstr "如果您不想重设密码,可以忽略此邮箱." +msgstr "若不想重设密码,可以忽略此邮件。" + +#: templates/password_reset/recovery_email.txt:11 +msgid "Yours Truly" +msgstr "感谢您的惠顾" + +#: templates/password_reset/recovery_email.txt:12 +msgid "Administrator" +msgstr "管理者" #: templates/password_reset/recovery_email_subject.txt:1 #, python-format msgid "Password recovery on %(domain)s" -msgstr "在 %(domain)s 上重设密码" +msgstr "重设 %(domain)s 密码" #: templates/password_reset/recovery_form.html:5 msgid "Password recovery" msgstr "重新设置密码" -#: templates/password_reset/recovery_form.html:11 +#: templates/password_reset/recovery_form.html:8 +msgid "Recover Password" +msgstr "重设密码" + +#: templates/password_reset/recovery_form.html:60 msgid "Recover my password" msgstr "重设密码" @@ -99,7 +119,8 @@ msgstr "重设密码" msgid "" "Sorry, this password reset link is invalid. You can still request a new one." -msgstr "抱歉,链接已经失效,您可以在 请求一个新链接." +msgstr "" +"抱歉,链接已经失效,您可以在 请求一个新链接." #: templates/password_reset/reset.html:7 #, python-format @@ -114,9 +135,26 @@ msgstr "设置新密码" msgid "Password recovery sent" msgstr "发送重设密码" -#: templates/password_reset/reset_sent.html:7 +#: templates/password_reset/reset_sent.html:8 #, python-format msgid "" "An email was sent to %(email)s %(ago)s ago. Use the link in " "it to set a new password." -msgstr "已经在%(ago)s以前向您的邮箱 %(email)s发送了邮件,根据邮件提示重新设置您的新密码." +msgstr "" +"已经在%(ago)s以前向您的邮箱 %(email)s 发送了邮件,请根据邮件" +"提示重新设置您的新密码." + +#: templates/password_reset/reset_sent.html:11 +#: templates/password_reset/reset_sent_failed.html:9 +msgid "Return to Main Page" +msgstr "回主页" + +#: templates/password_reset/reset_sent_failed.html:4 +msgid "Password recovery NOT successful" +msgstr "发送重设密码" + +#: templates/password_reset/reset_sent_failed.html:7 +msgid "" +"The email for this registered user is invalid, password reset information " +"cannot be sent. Please contact our administrator for assistance." +msgstr "此用户电邮地址无法接收密码重置信息。欢迎联系我们的客服部门。" diff --git a/password_reset/locale/zh/LC_MESSAGES/django.mo b/password_reset/locale/zh/LC_MESSAGES/django.mo deleted file mode 100644 index f1f11c35..00000000 Binary files a/password_reset/locale/zh/LC_MESSAGES/django.mo and /dev/null differ diff --git a/password_reset/locale/zh_Hans b/password_reset/locale/zh_Hans new file mode 120000 index 00000000..80d1c227 --- /dev/null +++ b/password_reset/locale/zh_Hans @@ -0,0 +1 @@ +zh-hans \ No newline at end of file diff --git a/password_reset/models.py b/password_reset/models.py new file mode 100644 index 00000000..e69de29b diff --git a/password_reset/templates/password_reset/recovery_email.txt b/password_reset/templates/password_reset/recovery_email.txt index 9c4af9cc..70b1af2a 100644 --- a/password_reset/templates/password_reset/recovery_email.txt +++ b/password_reset/templates/password_reset/recovery_email.txt @@ -7,3 +7,7 @@ http{% if secure %}s{% endif %}://{{ site.domain }}{% url "password_reset_reset" token %} {% trans "If you don't want to reset your password, simply ignore this email and it will stay unchanged." %} + +{% trans "Yours Truly" %}, +{% trans "Administrator" %} +{{ site.domain }} diff --git a/password_reset/templates/password_reset/recovery_form.html b/password_reset/templates/password_reset/recovery_form.html index e0aff0ca..4e152c0f 100644 --- a/password_reset/templates/password_reset/recovery_form.html +++ b/password_reset/templates/password_reset/recovery_form.html @@ -4,9 +4,42 @@ {% block title %}{% trans "Password recovery" %}{% endblock %} {% block content %} -
- {% csrf_token %} - {{ form.as_p }} -

-
-{% endblock %} +

{% trans "Recover Password" %}

+
+{% csrf_token %} +{% for field in form %} +{% if field.name != "captcha" %} +
+ {{ field.label_tag }} + +
+{% else %} +

+

+ + + +   {{ field }} +
+{% endif %} +
+ {{ field.errors }} +
+{% endfor %} + +
+
+ +
+
+ +
+{% endblock content %} diff --git a/password_reset/templates/password_reset/reset_sent.html b/password_reset/templates/password_reset/reset_sent.html index bc56b7c2..f5362f78 100644 --- a/password_reset/templates/password_reset/reset_sent.html +++ b/password_reset/templates/password_reset/reset_sent.html @@ -4,5 +4,11 @@ {% block title %}{% trans "Password recovery sent" %}{% endblock %} {% block content %} + + +{% trans "Return to Main Page" %} + + {% endblock %} diff --git a/password_reset/templates/password_reset/reset_sent_failed.html b/password_reset/templates/password_reset/reset_sent_failed.html new file mode 100644 index 00000000..77f3f6c3 --- /dev/null +++ b/password_reset/templates/password_reset/reset_sent_failed.html @@ -0,0 +1,11 @@ +{% extends "password_reset/base.html" %} +{% load i18n %} + +{% block title %}{% trans "Password recovery NOT successful" %}{% endblock %} + +{% block content %} +

{% trans "The email for this registered user is invalid, password reset information cannot be sent. Please contact our administrator for assistance." %}

+ +{% trans "Return to Main Page" %} + +{% endblock %} diff --git a/password_reset/tests/settings.py b/password_reset/tests/settings.py index f7c1479b..22c1f6db 100644 --- a/password_reset/tests/settings.py +++ b/password_reset/tests/settings.py @@ -9,12 +9,12 @@ }, } -INSTALLED_APPS = ( +INSTALLED_APPS = [ 'django.contrib.auth', 'django.contrib.contenttypes', 'password_reset', 'password_reset.tests', -) +] MIGRATION_MODULES = { 'auth': 'django.contrib.auth.tests.migrations', diff --git a/password_reset/tests/tests.py b/password_reset/tests/tests.py index b80b1dcd..31f579a4 100644 --- a/password_reset/tests/tests.py +++ b/password_reset/tests/tests.py @@ -11,7 +11,7 @@ except ImportError: from unittest import SkipTest -from ..forms import PasswordRecoveryForm, PasswordResetForm +from ..forms import PasswordRecoveryForm, PasswordResetForm, error_messages from ..utils import get_user_model if django.VERSION >= (1, 5): @@ -21,6 +21,14 @@ CustomUser = None # noqa ExtensionUser = None # noqa +if django.VERSION < (1, 6): + COLON_SUFFIX = '' # Django 1.5 or lower do NOT auto add colon suffix +else: + COLON_SUFFIX = ':' # Django 1.6 or higher auto add colon suffix + +# Test manually via ./manage.py test --settings password_reset.tests.settings +# password_test.tests + class CustomUserVariants(type): def __new__(cls, name, bases, dct): @@ -61,7 +69,7 @@ def test_username_input(self): form = PasswordRecoveryForm(data={'username_or_email': 'inexisting'}) self.assertFalse(form.is_valid()) self.assertEqual(form.errors['username_or_email'], - ["Sorry, this user doesn't exist."]) + [error_messages['not_found']]) create_user() @@ -133,7 +141,7 @@ def test_form_custom_search(self): }, search_fields=['email']) self.assertFalse(form.is_valid()) self.assertEqual(form.errors['username_or_email'], - ["Sorry, this user doesn't exist."]) + [error_messages['not_found']]) user = create_user() @@ -142,7 +150,7 @@ def test_form_custom_search(self): }, search_fields=['email']) self.assertFalse(form.is_valid()) self.assertEqual(form.errors['username_or_email'], - ["Sorry, this user doesn't exist."]) + [error_messages['not_found']]) # Search by actual email works form = PasswordRecoveryForm(data={ @@ -162,7 +170,7 @@ def test_form_custom_search(self): }, search_fields=['username']) self.assertFalse(form.is_valid()) self.assertEqual(form.errors['username_or_email'], - ["Sorry, this user doesn't exist."]) + [error_messages['not_found']]) form = PasswordRecoveryForm(data={ 'username_or_email': 'username', @@ -281,7 +289,8 @@ def test_email_recover(self): url = reverse('email_recover') response = self.client.get(url) self.assertNotContains(response, "Username or Email") - self.assertContains(response, "Email:") + + self.assertContains(response, "Email%s" % COLON_SUFFIX) response = self.client.post(url, {'username_or_email': 'foo'}) try: @@ -308,7 +317,7 @@ def test_username_recover(self): response = self.client.get(url) self.assertNotContains(response, "Username or Email") - self.assertContains(response, "Username:") + self.assertContains(response, "Username%s" % COLON_SUFFIX) response = self.client.post(url, {'username_or_email': 'bar@example.com'}) diff --git a/password_reset/tests/urls.py b/password_reset/tests/urls.py index 25832f3d..edc07682 100644 --- a/password_reset/tests/urls.py +++ b/password_reset/tests/urls.py @@ -1,8 +1,13 @@ -from django.conf.urls import url +from django.conf.urls import url, include from ..urls import urlpatterns from . import views +try: + import captcha as captcha_installed +except: + captcha_installed = None + urlpatterns += [ url(r'^email_recover/$', views.email_recover, name='email_recover'), url(r'^username_recover/$', views.username_recover, @@ -10,3 +15,8 @@ url(r'^insensitive_recover/$', views.insensitive_recover, name='insensitive_recover'), ] + +if captcha_installed: + urlpatterns += [ + url(r'^captcha/', include('captcha.urls')), + ] diff --git a/password_reset/urls.py b/password_reset/urls.py index eedad198..7b4817d9 100644 --- a/password_reset/urls.py +++ b/password_reset/urls.py @@ -6,6 +6,8 @@ urlpatterns = [ url(r'^recover/(?P.+)/$', views.recover_done, name='password_reset_sent'), + url(r'^recover/(?P.+)/$', views.recover_failed, + name='password_reset_sent_failed'), url(r'^recover/$', views.recover, name='password_reset_recover'), url(r'^reset/done/$', views.reset_done, name='password_reset_done'), url(r'^reset/(?P[\w:-]+)/$', views.reset, diff --git a/password_reset/utils.py b/password_reset/utils.py index 6200970f..dc82fa82 100644 --- a/password_reset/utils.py +++ b/password_reset/utils.py @@ -2,7 +2,12 @@ from django.contrib.auth import get_user_model except ImportError: from django.contrib.auth.models import User - get_user_model = lambda: User # noqa + # get_user_model = lambda: User # noqa + + def my_user(): # to satisfy lint in Travis auto build on Github + return User # noqa + + get_user_model = my_user def get_username(user): diff --git a/password_reset/views.py b/password_reset/views.py index 6be66ee0..e620822a 100644 --- a/password_reset/views.py +++ b/password_reset/views.py @@ -31,7 +31,7 @@ def loads_with_timestamp(value, salt): """Returns the unsigned value along with its timestamp, the time when it got dumped.""" try: - signing.loads(value, salt=salt, max_age=-1) + signing.loads(value, salt=salt, max_age=-999999) except signing.SignatureExpired as e: age = float(str(e).split('Signature age ')[1].split(' >')[0]) timestamp = timezone.now() - datetime.timedelta(seconds=age) @@ -53,17 +53,37 @@ def get_context_data(self, **kwargs): recover_done = RecoverDone.as_view() +class RecoverFailed(SaltMixin, generic.TemplateView): + template_name = 'password_reset/reset_sent_failed.html' + + def get_context_data(self, **kwargs): + ctx = super(RecoverFailed, self).get_context_data(**kwargs) + try: + ctx['timestamp'], ctx['email'] = loads_with_timestamp( + self.kwargs['signature'], salt=self.url_salt, + ) + except signing.BadSignature: + raise Http404 + return ctx +recover_failed = RecoverFailed.as_view() + + class Recover(SaltMixin, generic.FormView): case_sensitive = True form_class = PasswordRecoveryForm template_name = 'password_reset/recovery_form.html' success_url_name = 'password_reset_sent' + failure_url_name = 'password_reset_sent_failed' email_template_name = 'password_reset/recovery_email.txt' email_subject_template_name = 'password_reset/recovery_email_subject.txt' search_fields = ['username', 'email'] + sent_success = 0 def get_success_url(self): - return reverse(self.success_url_name, args=[self.mail_signature]) + if self.sent_success: + return reverse(self.success_url_name, args=[self.mail_signature]) + else: + return reverse(self.failure_url_name, args=[self.mail_signature]) def get_context_data(self, **kwargs): kwargs['url'] = self.request.get_full_path() @@ -92,15 +112,16 @@ def send_notification(self): context).strip() subject = loader.render_to_string(self.email_subject_template_name, context).strip() - send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, - [self.user.email]) + result = send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, + [self.user.email], fail_silently=True) + return result def form_valid(self, form): self.user = form.cleaned_data['user'] - self.send_notification() + self.sent_success = self.send_notification() if ( - len(self.search_fields) == 1 and - self.search_fields[0] == 'username' + len(self.search_fields) == 1 and + self.search_fields[0] == 'username' ): # if we only search by username, don't disclose the user email # since it may now be public information. diff --git a/setup.py b/setup.py index df82b271..b657ef5f 100644 --- a/setup.py +++ b/setup.py @@ -20,6 +20,7 @@ long_description=open('README.rst').read(), install_requires=[ 'Django>=1.4', + #'django-simple-captcha>=0.5.1', # will use captcha if installed ], classifiers=[ 'Development Status :: 4 - Beta',