From 95a804334160248dd9112cef1aa8e1c1e26e07fb Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Mon, 29 Dec 2025 10:58:44 -0300 Subject: [PATCH 01/25] Fix AttributeError when optional settings are undefined Fixes #1328 - Add None as default value in axes_conf_check - Add test coverage for missing settings scenario --- axes/checks.py | 2 +- tests/test_checks.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/axes/checks.py b/axes/checks.py index 3cd0e64..e6009b5 100644 --- a/axes/checks.py +++ b/axes/checks.py @@ -207,7 +207,7 @@ def axes_conf_check(app_configs, **kwargs): # pylint: disable=unused-argument ] for callable_setting in callable_settings: - value = getattr(settings, callable_setting) + value = getattr(settings, callable_setting, None) if not is_valid_callable(value): warnings.append( Warning( diff --git a/tests/test_checks.py b/tests/test_checks.py index b9ed5f6..821cf6e 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -129,3 +129,8 @@ class ConfCheckTestCase(AxesTestCase): def test_valid_callable(self): warnings = run_checks() self.assertEqual(warnings, []) + + def test_missing_settings_no_error(self): + warnings = run_checks() + self.assertEqual(warnings, []) + From 6703b66f175bc92179ad10576069e726487d3ddb Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Mon, 29 Dec 2025 11:23:19 -0300 Subject: [PATCH 02/25] Fix circular import with custom user models Fixes #1280 - Use SimpleLazyObject to defer get_user_model() evaluation - Prevents circular import when custom user models import from axes - Add test coverage for lazy evaluation in test_conf.py --- axes/conf.py | 9 ++++++++- tests/test_conf.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 tests/test_conf.py diff --git a/axes/conf.py b/axes/conf.py index 2de5a1b..33d58ec 100644 --- a/axes/conf.py +++ b/axes/conf.py @@ -1,5 +1,6 @@ from django.conf import settings from django.contrib.auth import get_user_model +from django.utils.functional import SimpleLazyObject from django.utils.translation import gettext_lazy as _ # disable plugin when set to False @@ -43,8 +44,14 @@ settings.AXES_ONLY_ADMIN_SITE = getattr(settings, "AXES_ONLY_ADMIN_SITE", False) settings.AXES_ENABLE_ADMIN = getattr(settings, "AXES_ENABLE_ADMIN", True) # use a specific username field to retrieve from login POST data +def _get_username_field_default(): + return get_user_model().USERNAME_FIELD + + settings.AXES_USERNAME_FORM_FIELD = getattr( - settings, "AXES_USERNAME_FORM_FIELD", get_user_model().USERNAME_FIELD + settings, + "AXES_USERNAME_FORM_FIELD", + SimpleLazyObject(_get_username_field_default), ) # use a specific password field to retrieve from login POST data diff --git a/tests/test_conf.py b/tests/test_conf.py new file mode 100644 index 0000000..2e9e39e --- /dev/null +++ b/tests/test_conf.py @@ -0,0 +1,45 @@ +from django.test import TestCase +from django.utils.functional import SimpleLazyObject + + +class ConfTestCase(TestCase): + def test_axes_username_form_field_uses_lazy_evaluation(self): + """ + Test that AXES_USERNAME_FORM_FIELD uses SimpleLazyObject for lazy evaluation. + This prevents circular import issues with custom user models (issue #1280). + """ + from axes.conf import settings + + # Verify that AXES_USERNAME_FORM_FIELD is a SimpleLazyObject if not overridden + # This is only the case when the setting is not explicitly defined + username_field = settings.AXES_USERNAME_FORM_FIELD + + # The actual type depends on whether AXES_USERNAME_FORM_FIELD was overridden + # If it's using the default, it should be a SimpleLazyObject + # If overridden in settings, it could be a plain string + # Either way, it should be usable as a string + + # Force evaluation and verify it works + username_field_str = str(username_field) + + # Should get the default USERNAME_FIELD from the user model + # For the test suite, this is "username" + self.assertIsInstance(username_field_str, str) + self.assertTrue(len(username_field_str) > 0) + + def test_axes_username_form_field_evaluates_correctly(self): + """ + Test that when AXES_USERNAME_FORM_FIELD is accessed, it correctly + resolves to the user model's USERNAME_FIELD. + """ + from django.contrib.auth import get_user_model + from axes.conf import settings + + # Get the expected value + expected_username_field = get_user_model().USERNAME_FIELD + + # Get the actual value from axes settings + actual_username_field = str(settings.AXES_USERNAME_FORM_FIELD) + + # They should match + self.assertEqual(actual_username_field, expected_username_field) From b14e861631dcdf32a4ed326702da05a70e35ae7c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 12:02:46 +0000 Subject: [PATCH 03/25] chore(deps): bump coverage from 7.13.0 to 7.13.3 Bumps [coverage](https://github.com/coveragepy/coveragepy) from 7.13.0 to 7.13.3. - [Release notes](https://github.com/coveragepy/coveragepy/releases) - [Changelog](https://github.com/coveragepy/coveragepy/blob/main/CHANGES.rst) - [Commits](https://github.com/coveragepy/coveragepy/compare/7.13.0...7.13.3) --- updated-dependencies: - dependency-name: coverage dependency-version: 7.13.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index fb0bf68..5f91e9a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -e . black==25.11.0 -coverage==7.13.0 +coverage==7.13.3 django-ipware>=3 mypy==1.19.1 prospector==1.17.3 From d033b70235d0c657f244a9acbcfc5f58903654d9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 12:02:40 +0000 Subject: [PATCH 04/25] chore(deps): bump prospector from 1.17.3 to 1.18.0 Bumps [prospector](https://github.com/prospector-dev/prospector) from 1.17.3 to 1.18.0. - [Release notes](https://github.com/prospector-dev/prospector/releases) - [Changelog](https://github.com/prospector-dev/prospector/blob/master/CHANGELOG.rst) - [Commits](https://github.com/prospector-dev/prospector/compare/v1.17.3...v1.18.0) --- updated-dependencies: - dependency-name: prospector dependency-version: 1.18.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5f91e9a..8e2a98e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ black==25.11.0 coverage==7.13.3 django-ipware>=3 mypy==1.19.1 -prospector==1.17.3 +prospector==1.18.0 pytest-cov==7.0.0 pytest-django==4.11.1 pytest-subtests==0.15.0 From cf0be90f1195e1af3b2cd40f9b3f29cc0e14daca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:09:27 +0000 Subject: [PATCH 05/25] chore(deps): bump sphinx-rtd-theme from 3.0.2 to 3.1.0 Bumps [sphinx-rtd-theme](https://github.com/readthedocs/sphinx_rtd_theme) from 3.0.2 to 3.1.0. - [Changelog](https://github.com/readthedocs/sphinx_rtd_theme/blob/master/docs/changelog.rst) - [Commits](https://github.com/readthedocs/sphinx_rtd_theme/compare/3.0.2...3.1.0) --- updated-dependencies: - dependency-name: sphinx-rtd-theme dependency-version: 3.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8e2a98e..a81f3b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,5 +8,5 @@ pytest-cov==7.0.0 pytest-django==4.11.1 pytest-subtests==0.15.0 pytest==9.0.2 -sphinx_rtd_theme==3.0.2 +sphinx_rtd_theme==3.1.0 tox==4.32.0 From 8f5e9965d8bff9531810610d11b0004933bafa9c Mon Sep 17 00:00:00 2001 From: shayan taki <76600102+shayanTaki@users.noreply.github.com> Date: Thu, 25 Dec 2025 02:41:05 -0800 Subject: [PATCH 06/25] Add unit tests for security check W006 --- tests/test_checks.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_checks.py b/tests/test_checks.py index 821cf6e..1f5fdaa 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -134,3 +134,19 @@ class ConfCheckTestCase(AxesTestCase): warnings = run_checks() self.assertEqual(warnings, []) + +class LockoutParametersCheckTestCase(AxesTestCase): + @override_settings(AXES_LOCKOUT_PARAMETERS=["ip_address", "username"]) + def test_valid_configuration(self): + warnings = run_checks() + self.assertEqual(warnings, []) + + @override_settings(AXES_LOCKOUT_PARAMETERS=["username", "user_agent"]) + def test_invalid_configuration(self): + warnings = run_checks() + warning = Warning( + msg=Messages.LOCKOUT_PARAMETERS_INVALID, + hint=Hints.LOCKOUT_PARAMETERS_INVALID, + id=Codes.LOCKOUT_PARAMETERS_INVALID, + ) + self.assertEqual(warnings, [warning]) From 60e3cceb1dc48a6de53162ae15753184473b4e51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:24:01 +0000 Subject: [PATCH 07/25] chore(deps): bump black from 25.11.0 to 26.1.0 Bumps [black](https://github.com/psf/black) from 25.11.0 to 26.1.0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/25.11.0...26.1.0) --- updated-dependencies: - dependency-name: black dependency-version: 26.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a81f3b8..0b876f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -e . -black==25.11.0 +black==26.1.0 coverage==7.13.3 django-ipware>=3 mypy==1.19.1 From 1d9964be163b993e620196e1343dcd444c3c4747 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:24:21 +0000 Subject: [PATCH 08/25] chore(deps): bump tox from 4.32.0 to 4.34.1 Bumps [tox](https://github.com/tox-dev/tox) from 4.32.0 to 4.34.1. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/main/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/4.32.0...4.34.1) --- updated-dependencies: - dependency-name: tox dependency-version: 4.34.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0b876f7..df737fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,4 @@ pytest-django==4.11.1 pytest-subtests==0.15.0 pytest==9.0.2 sphinx_rtd_theme==3.1.0 -tox==4.32.0 +tox==4.34.1 From b441ccd5fc61a001e417c51c586df0756a91b911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksi=20H=C3=A4kli?= Date: Fri, 6 Feb 2026 20:50:14 +0200 Subject: [PATCH 09/25] Version 8.2.0 --- CHANGES.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 072d6b0..d410a79 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ Changes ======= +8.2.0 (2026-02-06) +------------------ + +- Fix AttributeError when optional settings are undefined. + [rodrigo.nogueira] +- Fix circular import with custom user models. + [rodrigo.nogueira] +- Add unit tests for security check W006. + [shayanTaki] + 8.1.0 (2025-12-19) ------------------ From 6c8feada8377cf20a97fab0c5ff3925c620374f4 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 8 Feb 2026 23:59:13 +0200 Subject: [PATCH 10/25] Replace removed pkg_resources with stdlib --- docs/conf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 5df65ec..c436fd0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -7,7 +7,7 @@ More information on the configuration options is available at: """ # import sphinx_rtd_theme -from pkg_resources import get_distribution +from importlib.metadata import version as get_version import django from django.conf import settings @@ -43,7 +43,7 @@ copyright = "2016, Jazzband" author = "Jazzband" # The full version, including alpha/beta/rc tags. -release = get_distribution("django-axes").version +release = get_version("django-axes") # The short X.Y version. version = ".".join(release.split(".")[:2]) From b4fb3088b4c970926cef88c1f845fb4e5ef5ae4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksi=20H=C3=A4kli?= Date: Mon, 9 Feb 2026 10:37:38 +0200 Subject: [PATCH 11/25] Version 8.3.0 --- CHANGES.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index d410a79..c8e6677 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ Changes ======= +8.3.0 (2026-02-09) +------------------ + +- Remove deprecated pkg_resources in favour of new importlib. + [hugovk] + 8.2.0 (2026-02-06) ------------------ From 4ea615811be7046ac387108f6471e8df861414e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksi=20H=C3=A4kli?= Date: Wed, 11 Feb 2026 21:17:05 +0200 Subject: [PATCH 12/25] Implement custom lazy object to avoid JSON errors with Celery Fixes jazzband/django-axes#1391 --- axes/conf.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/axes/conf.py b/axes/conf.py index 33d58ec..6c3a59f 100644 --- a/axes/conf.py +++ b/axes/conf.py @@ -3,6 +3,18 @@ from django.contrib.auth import get_user_model from django.utils.functional import SimpleLazyObject from django.utils.translation import gettext_lazy as _ + +class JSONSerializableLazyObject(SimpleLazyObject): + """ + Celery/Kombu config inspection may JSON-encode Django settings. + Provide a JSON-friendly representation for lazy values. + + Fixes jazzband/django-axes#1391 + """ + def __json__(self): + return str(self) + + # disable plugin when set to False settings.AXES_ENABLED = getattr(settings, "AXES_ENABLED", True) @@ -51,7 +63,7 @@ def _get_username_field_default(): settings.AXES_USERNAME_FORM_FIELD = getattr( settings, "AXES_USERNAME_FORM_FIELD", - SimpleLazyObject(_get_username_field_default), + JSONSerializableLazyObject(_get_username_field_default), ) # use a specific password field to retrieve from login POST data From 23ee2fca44e76dc4090b977444de33bd464bb676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksi=20H=C3=A4kli?= Date: Wed, 11 Feb 2026 21:40:17 +0200 Subject: [PATCH 13/25] Update version support matrix to run tox QA tests properly on GitHub --- pyproject.toml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c930d27..9bedfc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,8 +13,8 @@ envlist = py{310,311,312}-dj42 py{310,311,312,313}-dj52 py{312,313,314}-dj60 - py312-djmain - py312-djqa + py314-djmain + py314-djqa [gh-actions] python = @@ -36,9 +36,9 @@ DJANGO = [testenv] deps = -r requirements.txt - dj42: django>=4.2,<5 - dj52: django>=5.2,<6 - dj60: django>=6.0,<7 + dj42: django>=4.2,<4.3 + dj52: django>=5.2,<5.3 + dj60: django>=6.0,<6.1 djmain: https://github.com/django/django/archive/main.tar.gz usedevelop = true commands = pytest @@ -51,7 +51,7 @@ ignore_errors = djmain: True # QA runs type checks, linting, and code formatting checks -[testenv:py312-djqa] +[testenv:py314-djqa] deps = -r requirements.txt commands = mypy axes From d59a2894079032f4f37d08461da63cec806e02bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksi=20H=C3=A4kli?= Date: Wed, 11 Feb 2026 21:53:12 +0200 Subject: [PATCH 14/25] Suppress mypy type errors Update Mypy Python version to 3.14 --- axes/admin.py | 2 +- axes/attempts.py | 2 +- axes/helpers.py | 4 ++-- mypy.ini | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/axes/admin.py b/axes/admin.py index a0c208c..b5a9581 100644 --- a/axes/admin.py +++ b/axes/admin.py @@ -44,7 +44,7 @@ class AccessAttemptAdmin(admin.ModelAdmin): # This will only add the status field if AXES_FAILURE_LIMIT is set to a positive integer # Because callable failure limit requires scope of request object list_display.append("status") - list_filter.append(IsLockedOutFilter) + list_filter.append(IsLockedOutFilter) # type: ignore[arg-type] search_fields = ["ip_address", "username", "user_agent", "path_info"] diff --git a/axes/attempts.py b/axes/attempts.py index 83367f9..830f07e 100644 --- a/axes/attempts.py +++ b/axes/attempts.py @@ -20,7 +20,7 @@ def get_cool_off_threshold(request: Optional[HttpRequest] = None) -> datetime: "Cool off threshold can not be calculated with settings.AXES_COOLOFF_TIME set to None" ) - attempt_time = request.axes_attempt_time + attempt_time = request.axes_attempt_time # type: ignore[union-attr] if attempt_time is None: return now() - cool_off return attempt_time - cool_off diff --git a/axes/helpers.py b/axes/helpers.py index a0a195e..d526566 100644 --- a/axes/helpers.py +++ b/axes/helpers.py @@ -111,7 +111,7 @@ def get_attempt_expiration(request: Optional[HttpRequest] = None) -> datetime: "Cool off threshold can not be calculated with settings.AXES_COOLOFF_TIME set to None" ) - attempt_time = request.axes_attempt_time + attempt_time = request.axes_attempt_time # type: ignore[union-attr] if attempt_time is None: return datetime.now() + cool_off return attempt_time + cool_off @@ -162,7 +162,7 @@ def get_client_username( log.debug( "Using parameter credentials to get username with key settings.AXES_USERNAME_FORM_FIELD" ) - return credentials.get(settings.AXES_USERNAME_FORM_FIELD, None) + return credentials.get(settings.AXES_USERNAME_FORM_FIELD, None) # type: ignore[return-value] log.debug( "Using parameter request.POST to get username with key settings.AXES_USERNAME_FORM_FIELD" diff --git a/mypy.ini b/mypy.ini index 822d184..ef0be81 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,5 @@ [mypy] -python_version = 3.12 +python_version = 3.14 ignore_missing_imports = True [mypy-axes.migrations.*] From 5acae054b41e592249777feefd6a7be7b020c89c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksi=20H=C3=A4kli?= Date: Wed, 11 Feb 2026 21:53:22 +0200 Subject: [PATCH 15/25] Update Black formatting rules --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9bedfc4..a48b899 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,5 +56,5 @@ deps = -r requirements.txt commands = mypy axes prospector - black -t py312 --check --diff axes + black -t py314 --check --diff axes """ From 4b77eb69eedc52ff9d32cfd1981c1446aadc1761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksi=20H=C3=A4kli?= Date: Wed, 11 Feb 2026 21:54:13 +0200 Subject: [PATCH 16/25] Run black autoformatting --- axes/admin.py | 28 +++++++++++++++++++--------- axes/checks.py | 2 +- axes/conf.py | 6 +++++- axes/handlers/database.py | 23 +++++++++++++++++------ axes/helpers.py | 2 ++ axes/middleware.py | 4 +++- axes/models.py | 5 ++++- 7 files changed, 51 insertions(+), 19 deletions(-) diff --git a/axes/admin.py b/axes/admin.py index b5a9581..6ab6690 100644 --- a/axes/admin.py +++ b/axes/admin.py @@ -19,7 +19,9 @@ class IsLockedOutFilter(admin.SimpleListFilter): def queryset(self, request, queryset): if self.value() == "yes": - return queryset.filter(failures_since_start__gte=settings.AXES_FAILURE_LIMIT) + return queryset.filter( + failures_since_start__gte=settings.AXES_FAILURE_LIMIT + ) elif self.value() == "no": return queryset.filter(failures_since_start__lt=settings.AXES_FAILURE_LIMIT) return queryset @@ -34,9 +36,9 @@ class AccessAttemptAdmin(admin.ModelAdmin): "path_info", "failures_since_start", ] - + if settings.AXES_USE_ATTEMPT_EXPIRATION: - list_display.append('expiration') + list_display.append("expiration") list_filter = ["attempt_time", "path_info"] @@ -51,7 +53,10 @@ class AccessAttemptAdmin(admin.ModelAdmin): date_hierarchy = "attempt_time" fieldsets = ( - (None, {"fields": ("username", "path_info", "failures_since_start", "expiration")}), + ( + None, + {"fields": ("username", "path_info", "failures_since_start", "expiration")}, + ), (_("Form Data"), {"fields": ("get_data", "post_data")}), (_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}), ) @@ -69,9 +74,9 @@ class AccessAttemptAdmin(admin.ModelAdmin): "expiration", ] - actions = ['cleanup_expired_attempts'] + actions = ["cleanup_expired_attempts"] - @admin.action(description=_('Clean up expired attempts')) + @admin.action(description=_("Clean up expired attempts")) def cleanup_expired_attempts(self, request, queryset): count = self.handler.clean_expired_user_attempts(request=request) self.message_user(request, _(f"Cleaned up {count} expired access attempts.")) @@ -85,10 +90,15 @@ class AccessAttemptAdmin(admin.ModelAdmin): def expiration(self, obj: AccessAttempt): return obj.expiration.expires_at if hasattr(obj, "expiration") else _("Not set") - + def status(self, obj: AccessAttempt): - return f"{settings.AXES_FAILURE_LIMIT - obj.failures_since_start} "+_("Attempt Remaining") if \ - obj.failures_since_start < settings.AXES_FAILURE_LIMIT else _("Locked Out") + return ( + f"{settings.AXES_FAILURE_LIMIT - obj.failures_since_start} " + + _("Attempt Remaining") + if obj.failures_since_start < settings.AXES_FAILURE_LIMIT + else _("Locked Out") + ) + class AccessLogAdmin(admin.ModelAdmin): list_display = ( diff --git a/axes/checks.py b/axes/checks.py index e6009b5..b16e879 100644 --- a/axes/checks.py +++ b/axes/checks.py @@ -235,4 +235,4 @@ def is_valid_callable(value) -> bool: except ImportError: return False - return True \ No newline at end of file + return True diff --git a/axes/conf.py b/axes/conf.py index 6c3a59f..e908401 100644 --- a/axes/conf.py +++ b/axes/conf.py @@ -11,6 +11,7 @@ class JSONSerializableLazyObject(SimpleLazyObject): Fixes jazzband/django-axes#1391 """ + def __json__(self): return str(self) @@ -55,6 +56,7 @@ settings.AXES_ONLY_ADMIN_SITE = getattr(settings, "AXES_ONLY_ADMIN_SITE", False) # show Axes logs in admin settings.AXES_ENABLE_ADMIN = getattr(settings, "AXES_ENABLE_ADMIN", True) + # use a specific username field to retrieve from login POST data def _get_username_field_default(): return get_user_model().USERNAME_FIELD @@ -106,7 +108,9 @@ settings.AXES_LOCKOUT_URL = getattr(settings, "AXES_LOCKOUT_URL", None) settings.AXES_COOLOFF_TIME = getattr(settings, "AXES_COOLOFF_TIME", None) -settings.AXES_USE_ATTEMPT_EXPIRATION = getattr(settings, "AXES_USE_ATTEMPT_EXPIRATION", False) +settings.AXES_USE_ATTEMPT_EXPIRATION = getattr( + settings, "AXES_USE_ATTEMPT_EXPIRATION", False +) settings.AXES_VERBOSE = getattr(settings, "AXES_VERBOSE", settings.AXES_ENABLED) diff --git a/axes/handlers/database.py b/axes/handlers/database.py index c344300..d9ce3f1 100644 --- a/axes/handlers/database.py +++ b/axes/handlers/database.py @@ -21,7 +21,12 @@ from axes.helpers import ( get_query_str, get_attempt_expiration, ) -from axes.models import AccessAttempt, AccessAttemptExpiration, AccessFailureLog, AccessLog +from axes.models import ( + AccessAttempt, + AccessAttemptExpiration, + AccessFailureLog, + AccessLog, +) from axes.signals import user_locked_out log = getLogger(__name__) @@ -223,15 +228,17 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler): if settings.AXES_USE_ATTEMPT_EXPIRATION: if not hasattr(attempt, "expiration") or attempt.expiration is None: log.debug( - "AXES: Creating new AccessAttemptExpiration for %s", client_str + "AXES: Creating new AccessAttemptExpiration for %s", + client_str, ) attempt.expiration = AccessAttemptExpiration.objects.create( access_attempt=attempt, - expires_at=get_attempt_expiration(request) + expires_at=get_attempt_expiration(request), ) else: attempt.expiration.expires_at = max( - get_attempt_expiration(request), attempt.expiration.expires_at + get_attempt_expiration(request), + attempt.expiration.expires_at, ) attempt.expiration.save() @@ -400,7 +407,9 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler): if settings.AXES_USE_ATTEMPT_EXPIRATION: threshold = timezone.now() - count, _ = AccessAttempt.objects.filter(expiration__expires_at__lte=threshold).delete() + count, _ = AccessAttempt.objects.filter( + expiration__expires_at__lte=threshold + ).delete() log.info( "AXES: Cleaned up %s expired access attempts from database that expiry were older than %s", count, @@ -408,7 +417,9 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler): ) else: threshold = get_cool_off_threshold(request) - count, _ = AccessAttempt.objects.filter(attempt_time__lte=threshold).delete() + count, _ = AccessAttempt.objects.filter( + attempt_time__lte=threshold + ).delete() log.info( "AXES: Cleaned up %s expired access attempts from database that were older than %s", count, diff --git a/axes/helpers.py b/axes/helpers.py index d526566..a7ccf60 100644 --- a/axes/helpers.py +++ b/axes/helpers.py @@ -100,6 +100,7 @@ def get_cool_off_iso8601(delta: timedelta) -> str: return f"P{days_str}T{time_str}" return f"P{days_str}" + def get_attempt_expiration(request: Optional[HttpRequest] = None) -> datetime: """ Get threshold for fetching access attempts from the database. @@ -116,6 +117,7 @@ def get_attempt_expiration(request: Optional[HttpRequest] = None) -> datetime: return datetime.now() + cool_off return attempt_time + cool_off + def get_credentials(username: Optional[str] = None, **kwargs) -> dict: """ Calculate credentials for Axes to use internally from given username and kwargs. diff --git a/axes/middleware.py b/axes/middleware.py index 189ee78..dd3824a 100644 --- a/axes/middleware.py +++ b/axes/middleware.py @@ -60,6 +60,8 @@ class AxesMiddleware: credentials = getattr(request, "axes_credentials", None) response = await sync_to_async( get_lockout_response, thread_sensitive=True - )(request, credentials) # type: ignore + )( + request, credentials + ) # type: ignore return response diff --git a/axes/models.py b/axes/models.py index 5658cab..fe2251b 100644 --- a/axes/models.py +++ b/axes/models.py @@ -68,9 +68,12 @@ class AccessAttemptExpiration(models.Model): verbose_name = _("access attempt expiration") verbose_name_plural = _("access attempt expirations") + class AccessLog(AccessBase): logout_time = models.DateTimeField(_("Logout Time"), null=True, blank=True) - session_hash = models.CharField(_("Session key hash (sha256)"), default="", blank=True, max_length=64) + session_hash = models.CharField( + _("Session key hash (sha256)"), default="", blank=True, max_length=64 + ) def __str__(self): return f"Access Log for {self.username} @ {self.attempt_time}" From bdd0c9546a108670a12d79857402707ea50fb1ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksi=20H=C3=A4kli?= Date: Wed, 11 Feb 2026 22:06:59 +0200 Subject: [PATCH 17/25] Fix prospector errors --- axes/admin.py | 4 ++-- axes/handlers/database.py | 6 ++++-- pyproject.toml | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/axes/admin.py b/axes/admin.py index 6ab6690..ce10a31 100644 --- a/axes/admin.py +++ b/axes/admin.py @@ -22,7 +22,7 @@ class IsLockedOutFilter(admin.SimpleListFilter): return queryset.filter( failures_since_start__gte=settings.AXES_FAILURE_LIMIT ) - elif self.value() == "no": + if self.value() == "no": return queryset.filter(failures_since_start__lt=settings.AXES_FAILURE_LIMIT) return queryset @@ -77,7 +77,7 @@ class AccessAttemptAdmin(admin.ModelAdmin): actions = ["cleanup_expired_attempts"] @admin.action(description=_("Clean up expired attempts")) - def cleanup_expired_attempts(self, request, queryset): + def cleanup_expired_attempts(self, request, queryset): # noqa count = self.handler.clean_expired_user_attempts(request=request) self.message_user(request, _(f"Cleaned up {count} expired access attempts.")) diff --git a/axes/handlers/database.py b/axes/handlers/database.py index d9ce3f1..375514e 100644 --- a/axes/handlers/database.py +++ b/axes/handlers/database.py @@ -372,7 +372,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler): return attempts_list def get_user_attempts( - self, request: HttpRequest, credentials: Optional[dict] = None + self, request: HttpRequest, credentials: Optional[dict] = None # noqa ) -> List[QuerySet]: """ Get list of querysets with valid user attempts that match the given request and credentials. @@ -393,7 +393,9 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler): ] def clean_expired_user_attempts( - self, request: Optional[HttpRequest] = None, credentials: Optional[dict] = None + self, + request: Optional[HttpRequest] = None, + credentials: Optional[dict] = None, # noqa ) -> int: """ Clean expired user attempts from the database. diff --git a/pyproject.toml b/pyproject.toml index a48b899..d7aabb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,6 @@ ignore_errors = deps = -r requirements.txt commands = mypy axes - prospector + prospector axes black -t py314 --check --diff axes """ From 31c69dbea5855a2b575a5abd8dbc2910b05c3348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksi=20H=C3=A4kli?= Date: Wed, 11 Feb 2026 22:07:28 +0200 Subject: [PATCH 18/25] Simplify black formatting rules --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d7aabb8..85e8dd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,5 +56,5 @@ deps = -r requirements.txt commands = mypy axes prospector axes - black -t py314 --check --diff axes + black --check --diff axes """ From 41ebdc3063bbebf9a214243815ad52fe832556b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksi=20H=C3=A4kli?= Date: Wed, 11 Feb 2026 22:09:16 +0200 Subject: [PATCH 19/25] Try to run all tox QA commands even if some fail --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 85e8dd2..8efe4e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ ignore_errors = # QA runs type checks, linting, and code formatting checks [testenv:py314-djqa] +stoponfail = false deps = -r requirements.txt commands = mypy axes From c3dcd1ba51cb2d00944d79db87aa62ade6e4d0ac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:25:45 +0000 Subject: [PATCH 20/25] chore(deps): bump coverage from 7.13.3 to 7.13.4 Bumps [coverage](https://github.com/coveragepy/coveragepy) from 7.13.3 to 7.13.4. - [Release notes](https://github.com/coveragepy/coveragepy/releases) - [Changelog](https://github.com/coveragepy/coveragepy/blob/main/CHANGES.rst) - [Commits](https://github.com/coveragepy/coveragepy/compare/7.13.3...7.13.4) --- updated-dependencies: - dependency-name: coverage dependency-version: 7.13.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index df737fe..9902e75 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -e . black==26.1.0 -coverage==7.13.3 +coverage==7.13.4 django-ipware>=3 mypy==1.19.1 prospector==1.18.0 From e27ce891ea612c2ddfdfec1395c08a207996f408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksi=20H=C3=A4kli?= Date: Wed, 11 Feb 2026 22:15:47 +0200 Subject: [PATCH 21/25] Version 8.3.1 --- CHANGES.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index c8e6677..11abcfb 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ Changes ======= +8.3.1 (2026-02-11) +------------------ + +- Fix configuration JSON serialization errors for Celery. + [aleksihakli] + 8.3.0 (2026-02-09) ------------------ From 4624eed6843b3a534bdc8502c48441cdd76d2229 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:59:40 +0000 Subject: [PATCH 22/25] chore(deps): bump pytest-django from 4.11.1 to 4.12.0 Bumps [pytest-django](https://github.com/pytest-dev/pytest-django) from 4.11.1 to 4.12.0. - [Release notes](https://github.com/pytest-dev/pytest-django/releases) - [Changelog](https://github.com/pytest-dev/pytest-django/blob/main/docs/changelog.rst) - [Commits](https://github.com/pytest-dev/pytest-django/compare/v4.11.1...v4.12.0) --- updated-dependencies: - dependency-name: pytest-django dependency-version: 4.12.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9902e75..da4c04e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ django-ipware>=3 mypy==1.19.1 prospector==1.18.0 pytest-cov==7.0.0 -pytest-django==4.11.1 +pytest-django==4.12.0 pytest-subtests==0.15.0 pytest==9.0.2 sphinx_rtd_theme==3.1.0 From 2a31c0133f8ca007b511355babfe46e0b27a3e22 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:02:49 +0000 Subject: [PATCH 23/25] chore(deps): bump tox from 4.34.1 to 4.49.1 Bumps [tox](https://github.com/tox-dev/tox) from 4.34.1 to 4.49.1. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/main/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/4.34.1...4.49.1) --- updated-dependencies: - dependency-name: tox dependency-version: 4.49.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index da4c04e..bb9ba6e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,4 @@ pytest-django==4.12.0 pytest-subtests==0.15.0 pytest==9.0.2 sphinx_rtd_theme==3.1.0 -tox==4.34.1 +tox==4.49.1 From a5d14cd6300fdbfb13929f2fdf2661b7c7565a54 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:02:38 +0000 Subject: [PATCH 24/25] chore(deps): bump black from 26.1.0 to 26.3.1 Bumps [black](https://github.com/psf/black) from 26.1.0 to 26.3.1. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/26.1.0...26.3.1) --- updated-dependencies: - dependency-name: black dependency-version: 26.3.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index bb9ba6e..471528a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -e . -black==26.1.0 +black==26.3.1 coverage==7.13.4 django-ipware>=3 mypy==1.19.1 From fdd7b22cd3d1024c4eb074231bfe0be2a56eb974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrico=20Tr=C3=B6ger?= Date: Mon, 23 Feb 2026 21:30:27 +0100 Subject: [PATCH 25/25] Clarify and/or conditions in AXES_LOCKOUT_PARAMETERS examples --- docs/5_customization.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/5_customization.rst b/docs/5_customization.rst index ec66f95..299a379 100644 --- a/docs/5_customization.rst +++ b/docs/5_customization.rst @@ -188,7 +188,7 @@ Example ``AXES_LOCKOUT_PARAMETERS`` configuration: AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]] -This way, axes will lock out users using ip_address and/or combination of username and user agent +This way, axes will lock out users using ip_address or combination of username and user_agent Example of callable ``AXES_LOCKOUT_PARAMETERS``: @@ -213,7 +213,7 @@ Example of callable ``AXES_LOCKOUT_PARAMETERS``: AXES_LOCKOUT_PARAMETERS = "example.utils.get_lockout_parameters" -This way, if client ip_address is localhost, axes will lockout client only by username. In other case, axes will lockout client by username and/or ip_address. +This way, if client ip_address is localhost, axes will lockout client only by username. In other case, axes will lockout client by username or ip_address. Customizing client ip address lookups -------------------------------------