Repository: jazzband/django-axes

Issue:
Add ability to specify AXES_COOLOFF_TIME as callable object
It's useful that ``AXES_FAILURE_LIMIT`` can be not only hardcoded value but also callable object, that returns value at runtime. But it's not possible for setting ``AXES_COOLOFF_TIME``. 
I'd like to contribute to source code to add this functionality asap.



Discussion:
Sounds like a good idea, thanks for your interest! Jazzband is always open for PRs. 

The current implementation for getting the cooloff is [here](https://github.com/jazzband/django-axes/blob/2fcf439683d09b297575e316b01620a20721ed56/axes/helpers.py#L44) and it is called from [here](https://github.com/jazzband/django-axes/blob/2fcf439683d09b297575e316b01620a20721ed56/axes/helpers.py#L272) and [here](https://github.com/jazzband/django-axes/blob/26cc41b68927e4044674f4e2ef87057c30663df4/axes/attempts.py#L18).

Both call chains have `request` attribute in them. It might be nice to have the `request` as an attribute for resolving the cooloff time in the callable if possible. Credentials are also supplied, and could be similarly passed to the callable like in other callable configuration interfaces.

Please bear in mind that the cooloff needs to be usable from the queryset filtering and it needs to be well tested, so if you add new features, they need to have tests for getting upstreamed. The cooloff calculation is a central feature and needs to work correctly in order for security features to function.

Resolving patch:
diff --git a/axes/helpers.py b/axes/helpers.py
--- a/axes/helpers.py
+++ b/axes/helpers.py
@@ -30,7 +30,7 @@ def get_cache_timeout() -> Optional[int]:
 
     The cache timeout can be either None if not configured or integer of seconds if configured.
 
-    Notice that the settings.AXES_COOLOFF_TIME can be None, timedelta, or integer of hours,
+    Notice that the settings.AXES_COOLOFF_TIME can be None, timedelta, integer, callable, or str path,
     and this function offers a unified _integer or None_ representation of that configuration
     for use with the Django cache backends.
     """
@@ -58,6 +58,11 @@ def get_cool_off() -> Optional[timedelta]:
 
     if isinstance(cool_off, int):
         return timedelta(hours=cool_off)
+    if isinstance(cool_off, str):
+        return import_string(cool_off)()
+    if callable(cool_off):
+        return cool_off()
+
     return cool_off
 
 


Test patch:
diff --git a/axes/tests/test_helpers.py b/axes/tests/test_helpers.py
new file mode 100644
--- /dev/null
+++ b/axes/tests/test_helpers.py
@@ -0,0 +1,29 @@
+from datetime import timedelta
+from unittest.mock import patch
+
+from django.test import override_settings
+
+from axes.helpers import get_cool_off
+from axes.tests.base import AxesTestCase
+
+
+def get_cool_off_str():
+    return timedelta(seconds=30)
+
+
+class AxesHelpersTestCase(AxesTestCase):
+    @override_settings(AXES_COOLOFF_TIME=None)
+    def test_get_cool_off_none(self):
+        self.assertIsNone(get_cool_off())
+
+    @override_settings(AXES_COOLOFF_TIME=2)
+    def test_get_cool_off_int(self):
+        self.assertEqual(get_cool_off(), timedelta(hours=2))
+
+    @override_settings(AXES_COOLOFF_TIME=lambda: timedelta(seconds=30))
+    def test_get_cool_off_callable(self):
+        self.assertEqual(get_cool_off(), timedelta(seconds=30))
+
+    @override_settings(AXES_COOLOFF_TIME='axes.tests.test_helpers.get_cool_off_str')
+    def test_get_cool_off_str(self):
+        self.assertEqual(get_cool_off(), timedelta(seconds=30))