From cfeb1fda7eb3232834c3d3546eb48e21204c5cef Mon Sep 17 00:00:00 2001 From: bgaudino Date: Tue, 23 Dec 2025 15:41:45 -0600 Subject: [PATCH 01/11] Fix BetterReadOnlyPasswordHashWidget for django 6.0 --- .github/workflows/ci.yml | 9 +++++++ authtools/forms.py | 7 +----- .../better_read_only_password_hash.html | 5 ++++ authtools/templatetags/authtools.py | 25 +++++++++++++++++++ tests/tests/tests.py | 10 +------- tox.ini | 7 ++++-- 6 files changed, 46 insertions(+), 17 deletions(-) create mode 100644 authtools/templates/authtools/widgets/better_read_only_password_hash.html create mode 100644 authtools/templatetags/authtools.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7da1d9..f4062d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,16 @@ jobs: python-version: "3.12" - django-version: "5.2" python-version: "3.13" + - django-version: "5.2" + python-version: "3.14" + # Django 6.0 + - django-version: "6.0" + python-version: "3.12" + - django-version: "6.0" + python-version: "3.13" + - django-version: "6.0" + python-version: "3.14" steps: - uses: actions/checkout@v4 diff --git a/authtools/forms.py b/authtools/forms.py index 7ea02a7..769049a 100644 --- a/authtools/forms.py +++ b/authtools/forms.py @@ -31,12 +31,7 @@ class BetterReadOnlyPasswordHashWidget(ReadOnlyPasswordHashWidget): """ A ReadOnlyPasswordHashWidget that has a less intimidating output. """ - - def get_context(self, name, value, attrs): - context = super().get_context(name, value, attrs) - if any(item.get('value') for item in context['summary']): - context['summary'] = [{'label': gettext('*************')}] - return context + template_name = 'authtools/widgets/better_read_only_password_hash.html' class UserChangeForm(DjangoUserChangeForm): diff --git a/authtools/templates/authtools/widgets/better_read_only_password_hash.html b/authtools/templates/authtools/widgets/better_read_only_password_hash.html new file mode 100644 index 0000000..197b83b --- /dev/null +++ b/authtools/templates/authtools/widgets/better_read_only_password_hash.html @@ -0,0 +1,5 @@ +{% load authtools %} + + {% render_better_read_only_password_hash widget.value %} +

{{ button_label }}

+ \ No newline at end of file diff --git a/authtools/templatetags/authtools.py b/authtools/templatetags/authtools.py new file mode 100644 index 0000000..f5fb861 --- /dev/null +++ b/authtools/templatetags/authtools.py @@ -0,0 +1,25 @@ +from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX, identify_hasher +from django.template import Library +from django.utils.html import format_html +from django.utils.translation import gettext + +register = Library() + + +@register.simple_tag +def render_better_read_only_password_hash(value): + if not value or value.startswith(UNUSABLE_PASSWORD_PREFIX): + return format_html("

{}

", gettext("No password set.")) + try: + hasher = identify_hasher(value) + hasher.safe_summary(value) + except ValueError: + return format_html( + "

{}

", + gettext("Invalid password format or unknown hashing algorithm."), + ) + + return format_html( + "

{}

", + gettext("*************"), + ) diff --git a/tests/tests/tests.py b/tests/tests/tests.py index badbc2f..8e520ff 100644 --- a/tests/tests/tests.py +++ b/tests/tests/tests.py @@ -258,15 +258,7 @@ def test_better_readonly_password_widget(self): form = UserChangeForm(instance=user) self.assertIn(_('*************'), form.as_table()) - - version = django.VERSION[0] - - if version < 4: - self.assertIn('', form.as_table()) - elif version < 5: - self.assertIn(''.format(user.id), form.as_table()) - else: - self.assertIn('', form.as_table()) + self.assertIn('', form.as_table()) class UserAdminTest(TestCase): diff --git a/tox.ini b/tox.ini index 32835ef..ad99cdf 100644 --- a/tox.ini +++ b/tox.ini @@ -3,8 +3,9 @@ envlist= py37-dj{22,30,32,32} py{38,39}-dj{22,30,31,32,40,41,42} py{10}-dj{32,40,41,42,50,51,52} - py{11,12}-dj{42,50,51,52} - py313-dj{51,52} + py{11,12}-dj{42,50,51,52,60} + py313-dj{51,52,60} + py314-dj60 [testenv] python= py37: python3.7 @@ -14,6 +15,7 @@ python= py311: python3.11 py312: python3.12 py313: python3.13 + py314: python3.14 commands= /usr/bin/env make test @@ -28,6 +30,7 @@ deps= dj50: Django>=5.0,<5.1 dj51: Django>=5.1,<5.2 dj52: Django>=5.2,<5.3 + dj60: Django>=6.0,<6.1 whitelist_externals= env make From 93960b6391ce43fbf6eb2c65e7d501ac1de6e864 Mon Sep 17 00:00:00 2001 From: bgaudino Date: Tue, 13 Jan 2026 09:46:59 -0600 Subject: [PATCH 02/11] Remove unused imports --- authtools/forms.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/authtools/forms.py b/authtools/forms.py index 769049a..7a234ef 100644 --- a/authtools/forms.py +++ b/authtools/forms.py @@ -1,17 +1,15 @@ from __future__ import unicode_literals from django import forms -from django.forms.utils import flatatt from django.contrib.auth.forms import ( - ReadOnlyPasswordHashField, ReadOnlyPasswordHashWidget, + ReadOnlyPasswordHashWidget, PasswordResetForm as OldPasswordResetForm, UserChangeForm as DjangoUserChangeForm, AuthenticationForm as DjangoAuthenticationForm, ) from django.contrib.auth import get_user_model, password_validation -from django.contrib.auth.hashers import identify_hasher, UNUSABLE_PASSWORD_PREFIX -from django.utils.translation import gettext_lazy as _, gettext -from django.utils.html import format_html +from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX +from django.utils.translation import gettext_lazy as _ User = get_user_model() From 4e775346c171f3e89236768984859700946557d9 Mon Sep 17 00:00:00 2001 From: bgaudino Date: Tue, 13 Jan 2026 10:01:39 -0600 Subject: [PATCH 03/11] Password change link is styled as button in django >= 5.1 --- authtools/forms.py | 8 +++++++- .../authtools/widgets/better_read_only_password_hash.html | 2 +- tests/tests/tests.py | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/authtools/forms.py b/authtools/forms.py index 7a234ef..8f857b8 100644 --- a/authtools/forms.py +++ b/authtools/forms.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from django import forms +from django import forms, VERSION from django.contrib.auth.forms import ( ReadOnlyPasswordHashWidget, PasswordResetForm as OldPasswordResetForm, @@ -31,6 +31,12 @@ class BetterReadOnlyPasswordHashWidget(ReadOnlyPasswordHashWidget): """ template_name = 'authtools/widgets/better_read_only_password_hash.html' + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + version = float(f'{VERSION[0]}.{VERSION[1]}') + context['is_button'] = version >= 5.1 + return context + class UserChangeForm(DjangoUserChangeForm): def __init__(self, *args, **kwargs): diff --git a/authtools/templates/authtools/widgets/better_read_only_password_hash.html b/authtools/templates/authtools/widgets/better_read_only_password_hash.html index 197b83b..7b9c22a 100644 --- a/authtools/templates/authtools/widgets/better_read_only_password_hash.html +++ b/authtools/templates/authtools/widgets/better_read_only_password_hash.html @@ -1,5 +1,5 @@ {% load authtools %} {% render_better_read_only_password_hash widget.value %} -

{{ button_label }}

+

{{ button_label }}

\ No newline at end of file diff --git a/tests/tests/tests.py b/tests/tests/tests.py index 8e520ff..3be4c15 100644 --- a/tests/tests/tests.py +++ b/tests/tests/tests.py @@ -258,7 +258,7 @@ def test_better_readonly_password_widget(self): form = UserChangeForm(instance=user) self.assertIn(_('*************'), form.as_table()) - self.assertIn('', form.as_table()) + self.assertIn(' Date: Thu, 29 Jan 2026 10:23:43 -0600 Subject: [PATCH 04/11] Safer version comparison --- authtools/forms.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/authtools/forms.py b/authtools/forms.py index 8f857b8..7d2ecf3 100644 --- a/authtools/forms.py +++ b/authtools/forms.py @@ -33,8 +33,7 @@ class BetterReadOnlyPasswordHashWidget(ReadOnlyPasswordHashWidget): def get_context(self, name, value, attrs): context = super().get_context(name, value, attrs) - version = float(f'{VERSION[0]}.{VERSION[1]}') - context['is_button'] = version >= 5.1 + context['is_button'] = VERSION[:2] >= (5, 1) return context From 792b1ae9e98140942b8cd5e0ccbd88a1ac866be7 Mon Sep 17 00:00:00 2001 From: bgaudino Date: Thu, 29 Jan 2026 10:24:01 -0600 Subject: [PATCH 05/11] Formatting --- .../authtools/widgets/better_read_only_password_hash.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authtools/templates/authtools/widgets/better_read_only_password_hash.html b/authtools/templates/authtools/widgets/better_read_only_password_hash.html index 7b9c22a..2b52c44 100644 --- a/authtools/templates/authtools/widgets/better_read_only_password_hash.html +++ b/authtools/templates/authtools/widgets/better_read_only_password_hash.html @@ -2,4 +2,4 @@ {% render_better_read_only_password_hash widget.value %}

{{ button_label }}

- \ No newline at end of file + From ecc5d67570a3333af76b08cd0bee408587b19382 Mon Sep 17 00:00:00 2001 From: bgaudino Date: Thu, 29 Jan 2026 11:42:25 -0600 Subject: [PATCH 06/11] Remove duplicate link in older versions --- authtools/forms.py | 5 ----- .../authtools/widgets/better_read_only_password_hash.html | 4 +++- tests/tests/tests.py | 7 ++++++- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/authtools/forms.py b/authtools/forms.py index 7d2ecf3..e4129c7 100644 --- a/authtools/forms.py +++ b/authtools/forms.py @@ -31,11 +31,6 @@ class BetterReadOnlyPasswordHashWidget(ReadOnlyPasswordHashWidget): """ template_name = 'authtools/widgets/better_read_only_password_hash.html' - def get_context(self, name, value, attrs): - context = super().get_context(name, value, attrs) - context['is_button'] = VERSION[:2] >= (5, 1) - return context - class UserChangeForm(DjangoUserChangeForm): def __init__(self, *args, **kwargs): diff --git a/authtools/templates/authtools/widgets/better_read_only_password_hash.html b/authtools/templates/authtools/widgets/better_read_only_password_hash.html index 2b52c44..3630e7b 100644 --- a/authtools/templates/authtools/widgets/better_read_only_password_hash.html +++ b/authtools/templates/authtools/widgets/better_read_only_password_hash.html @@ -1,5 +1,7 @@ {% load authtools %} {% render_better_read_only_password_hash widget.value %} -

{{ button_label }}

+ {% if button_label %} +

{{ button_label }}

+ {% endif %} diff --git a/tests/tests/tests.py b/tests/tests/tests.py index 3be4c15..4d772ec 100644 --- a/tests/tests/tests.py +++ b/tests/tests/tests.py @@ -258,7 +258,12 @@ def test_better_readonly_password_widget(self): form = UserChangeForm(instance=user) self.assertIn(_('*************'), form.as_table()) - self.assertIn(''.format(user.id), form.as_table()) + else: + self.assertIn(' Date: Thu, 29 Jan 2026 11:42:36 -0600 Subject: [PATCH 07/11] Forgot init file --- authtools/templatetags/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 authtools/templatetags/__init__.py diff --git a/authtools/templatetags/__init__.py b/authtools/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 From fdf5482bc1125a00e6f8971fcedcfc0807f8cb7e Mon Sep 17 00:00:00 2001 From: bgaudino Date: Thu, 29 Jan 2026 11:42:53 -0600 Subject: [PATCH 08/11] Include templates in manifest --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index 0081f2f..23c26be 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,6 +2,7 @@ include README.rst CHANGES.rst CONTRIBUTING.rst RELEASES.rst LICENSE Makefile include requirements-dev.txt include tox.ini include .readthedocs.yaml +recursive-include authtools/templates * recursive-include tests *.py recursive-include tests *.json recursive-include docs * From 01b59d6f25ba2b02e56e3407df096aead6bf713a Mon Sep 17 00:00:00 2001 From: bgaudino Date: Thu, 29 Jan 2026 12:10:47 -0600 Subject: [PATCH 09/11] Remove unused code --- authtools/forms.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/authtools/forms.py b/authtools/forms.py index e4129c7..372375b 100644 --- a/authtools/forms.py +++ b/authtools/forms.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from django import forms, VERSION +from django import forms from django.contrib.auth.forms import ( ReadOnlyPasswordHashWidget, PasswordResetForm as OldPasswordResetForm, @@ -8,23 +8,11 @@ AuthenticationForm as DjangoAuthenticationForm, ) from django.contrib.auth import get_user_model, password_validation -from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX from django.utils.translation import gettext_lazy as _ User = get_user_model() -def is_password_usable(pw): - """Decide whether a password is usable only by the unusable password prefix. - - We can't use django.contrib.auth.hashers.is_password_usable either, because - it not only checks against the unusable password, but checks for a valid - hasher too. We need different error messages in those cases. - """ - - return not pw.startswith(UNUSABLE_PASSWORD_PREFIX) - - class BetterReadOnlyPasswordHashWidget(ReadOnlyPasswordHashWidget): """ A ReadOnlyPasswordHashWidget that has a less intimidating output. From 16513fef4755c365594c9082df3db2c0a681106d Mon Sep 17 00:00:00 2001 From: bgaudino Date: Thu, 29 Jan 2026 12:25:41 -0600 Subject: [PATCH 10/11] Test for button class --- tests/tests/tests.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/tests/tests.py b/tests/tests/tests.py index 4d772ec..016ac51 100644 --- a/tests/tests/tests.py +++ b/tests/tests/tests.py @@ -257,13 +257,16 @@ def test_better_readonly_password_widget(self): user = User.objects.get(username='testclient') form = UserChangeForm(instance=user) - self.assertIn(_('*************'), form.as_table()) + html = form.as_table() + self.assertIn(_('*************'), html) version = django.VERSION[:2] - if version in ((4, 2), (5, 0)): - self.assertIn(''.format(user.id), form.as_table()) + if version < (4, 2): + self.assertIn(''.format(user.id), html) else: - self.assertIn(' Date: Fri, 30 Jan 2026 15:31:46 -0600 Subject: [PATCH 11/11] Update tox.ini for tox 4 and fix typos --- tox.ini | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index ad99cdf..ae8eb36 100644 --- a/tox.ini +++ b/tox.ini @@ -2,8 +2,9 @@ envlist= py37-dj{22,30,32,32} py{38,39}-dj{22,30,31,32,40,41,42} - py{10}-dj{32,40,41,42,50,51,52} - py{11,12}-dj{42,50,51,52,60} + py{310}-dj{32,40,41,42,50,51,52} + py311-dj{42,50,51,52} + py12-dj{42,50,51,52,60} py313-dj{51,52,60} py314-dj60 [testenv] @@ -17,7 +18,6 @@ python= py313: python3.13 py314: python3.14 commands= - /usr/bin/env make test deps= dj22: Django>=2.2,<2.3 @@ -31,6 +31,6 @@ deps= dj51: Django>=5.1,<5.2 dj52: Django>=5.2,<5.3 dj60: Django>=6.0,<6.1 -whitelist_externals= +allowlist_externals= env make