Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-django-otp for 
openSUSE:Factory checked in at 2026-02-17 18:14:02
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-django-otp (Old)
 and      /work/SRC/openSUSE:Factory/.python-django-otp.new.1977 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-django-otp"

Tue Feb 17 18:14:02 2026 rev:5 rq:1333421 version:1.7.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-django-otp/python-django-otp.changes      
2025-11-06 18:18:29.800866935 +0100
+++ 
/work/SRC/openSUSE:Factory/.python-django-otp.new.1977/python-django-otp.changes
    2026-02-17 18:14:08.248908482 +0100
@@ -1,0 +2,7 @@
+Mon Feb 16 17:27:46 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 1.7.0:
+  * #185: Make OTPMiddleware async capable
+  * #182: Correct missing Spanish translations
+
+-------------------------------------------------------------------

Old:
----
  django_otp-1.6.3.tar.gz

New:
----
  django_otp-1.7.0.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-django-otp.spec ++++++
--- /var/tmp/diff_new_pack.tDgyr8/_old  2026-02-17 18:14:09.368955151 +0100
+++ /var/tmp/diff_new_pack.tDgyr8/_new  2026-02-17 18:14:09.372955317 +0100
@@ -1,7 +1,7 @@
 #
 # spec file for package python-django-otp
 #
-# Copyright (c) 2025 SUSE LLC and contributors
+# Copyright (c) 2026 SUSE LLC and contributors
 #
 # All modifications and additions to the file contributed by third parties
 # remain the property of their copyright owners, unless otherwise agreed
@@ -17,7 +17,7 @@
 
 
 Name:           python-django-otp
-Version:        1.6.3
+Version:        1.7.0
 Release:        0
 Summary:        Add two-factor authentication to Django using one-time 
passwords
 License:        Unlicense

++++++ django_otp-1.6.3.tar.gz -> django_otp-1.7.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django_otp-1.6.3/CHANGES.rst 
new/django_otp-1.7.0/CHANGES.rst
--- old/django_otp-1.6.3/CHANGES.rst    2020-02-02 01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/CHANGES.rst    2020-02-02 01:00:00.000000000 +0100
@@ -1,3 +1,13 @@
+v1.7.0 - January 07, 2026 - Async support
+--------------------------------------------------------------------------------
+
+- `#185`_: Make OTPMiddleware async capable
+
+Thanks to Aljosha Papsch.
+
+.. _#185: https://github.com/django-otp/django-otp/pull/185
+
+
 v1.6.3 - October 25, 2025 - Spanish update
 
--------------------------------------------------------------------------------
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django_otp-1.6.3/PKG-INFO 
new/django_otp-1.7.0/PKG-INFO
--- old/django_otp-1.6.3/PKG-INFO       2020-02-02 01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/PKG-INFO       2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: django-otp
-Version: 1.6.3
+Version: 1.7.0
 Summary: A pluggable framework for adding two-factor authentication to Django 
using one-time passwords.
 Project-URL: Homepage, https://github.com/django-otp/django-otp
 Project-URL: Documentation, https://django-otp-official.readthedocs.io/
@@ -9,16 +9,13 @@
 License-File: LICENSE
 Classifier: Development Status :: 5 - Production/Stable
 Classifier: Framework :: Django
-Classifier: Framework :: Django :: 4.2
-Classifier: Framework :: Django :: 5.1
-Classifier: Framework :: Django :: 5.2
 Classifier: Intended Audience :: Developers
 Classifier: License :: OSI Approved :: The Unlicense (Unlicense)
 Classifier: Programming Language :: Python :: 3
 Classifier: Programming Language :: Python :: 3 :: Only
 Classifier: Topic :: Security
 Classifier: Topic :: Software Development :: Libraries :: Python Modules
-Requires-Python: >=3.7
+Requires-Python: >=3.8
 Requires-Dist: django>=4.2
 Provides-Extra: qrcode
 Requires-Dist: qrcode; extra == 'qrcode'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django_otp-1.6.3/docs/source/conf.py 
new/django_otp-1.7.0/docs/source/conf.py
--- old/django_otp-1.6.3/docs/source/conf.py    2020-02-02 01:00:00.000000000 
+0100
+++ new/django_otp-1.7.0/docs/source/conf.py    2020-02-02 01:00:00.000000000 
+0100
@@ -89,7 +89,7 @@
 # built documents.
 #
 # The full version, including alpha/beta/rc tags.
-release = '1.6.3'
+release = '1.7.0'
 
 # The short X.Y version.
 version = '.'.join(release.split('.')[:2])
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django_otp-1.6.3/docs/source/overview.rst 
new/django_otp-1.7.0/docs/source/overview.rst
--- old/django_otp-1.6.3/docs/source/overview.rst       2020-02-02 
01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/docs/source/overview.rst       2020-02-02 
01:00:00.000000000 +0100
@@ -162,6 +162,9 @@
 counterpart to ``user.is_authenticated()``. It is not possible for a user to be
 verified without also being authenticated. [#agents]_
 
+The middleware is async capable. Using ``await request.auser()`` returns the
+user model augmented with the same properties as when using ``request.user``.
+
 
 Plugins and Devices
 -------------------
@@ -522,6 +525,15 @@
     - `django-otp-twilio`_ supports delivering tokens via Twilio's SMS service.
 
 
+Asynchronous Support
+--------------------
+
+:class:`django_otp.middleware.OTPMiddleware` is async capable, ensuring there
+is no context switch caused by this middleware in ASGI servers. Both
+``request.user`` and ``request.auser()`` return the user model augmented with
+additional properties.
+
+
 Settings
 --------
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django_otp-1.6.3/pyproject.toml 
new/django_otp-1.7.0/pyproject.toml
--- old/django_otp-1.6.3/pyproject.toml 2020-02-02 01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/pyproject.toml 2020-02-02 01:00:00.000000000 +0100
@@ -1,9 +1,9 @@
 [project]
 name = "django-otp"
-version = "1.6.3"
+version = "1.7.0"
 description = "A pluggable framework for adding two-factor authentication to 
Django using one-time passwords."
 readme = "README.rst"
-requires-python = ">=3.7"
+requires-python = ">=3.8"
 license = "Unlicense"
 authors = [
     { name = "Peter Sagerson", email = "[email protected]" },
@@ -11,9 +11,6 @@
 classifiers = [
     "Development Status :: 5 - Production/Stable",
     "Framework :: Django",
-    "Framework :: Django :: 4.2",
-    "Framework :: Django :: 5.1",
-    "Framework :: Django :: 5.2",
     "Intended Audience :: Developers",
     "License :: OSI Approved :: The Unlicense (Unlicense)",
     "Programming Language :: Python :: 3",
@@ -91,8 +88,8 @@
 [tool.hatch.envs.test.overrides]
 matrix.django.dependencies = [
     { value = "django ~= 4.2.0", if = ["4.2"] },
-    { value = "django ~= 5.1.0", if = ["5.1"] },
     { value = "django ~= 5.2.0", if = ["5.2"] },
+    { value = "django ~= 6.0.0", if = ["6.0"] },
 ]
 matrix.mode.scripts = [
     { key = "run", value = "lint", if = ["lint"] },
@@ -110,11 +107,11 @@
 
 [[tool.hatch.envs.test.matrix]]
 python = ["3.11"]
-django = ["5.1"]
+django = ["5.2"]
 
 [[tool.hatch.envs.test.matrix]]
 python = ["3.13"]
-django = ["5.2"]
+django = ["6.0"]
 
 [[tool.hatch.envs.test.matrix]]
 mode = ["coverage"]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django_otp-1.6.3/src/django_otp/middleware.py 
new/django_otp-1.7.0/src/django_otp/middleware.py
--- old/django_otp-1.6.3/src/django_otp/middleware.py   2020-02-02 
01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/src/django_otp/middleware.py   2020-02-02 
01:00:00.000000000 +0100
@@ -1,5 +1,7 @@
 import functools
 
+from asgiref.sync import iscoroutinefunction, markcoroutinefunction
+
 from django.utils.functional import SimpleLazyObject
 
 from django_otp import DEVICE_ID_SESSION_KEY
@@ -20,27 +22,76 @@
     object that has verified the user, or ``None`` if the user has not been
     verified.  As a convenience, this also installs ``user.is_verified()``,
     which returns ``True`` if ``user.otp_device`` is not ``None``.
+
+    This middleware is async capable. It wraps ``request.auser()`` similarly
+    to ``request.user`` as described above.
     """
 
-    def __init__(self, get_response=None):
+    sync_capable = True
+    async_capable = True
+
+    def __init__(self, get_response):
         self.get_response = get_response
+        self._is_async = iscoroutinefunction(get_response)
+        if self._is_async:
+            markcoroutinefunction(self)
 
     def __call__(self, request):
-        user = getattr(request, 'user', None)
+        if self._is_async:
+            return self.__acall__(request)
+
+        self._install_lazy_accessors(request)
+        return self.get_response(request)
+
+    async def __acall__(self, request):
+        self._install_lazy_accessors(request)
+        return await self.get_response(request)
+
+    def _install_lazy_accessors(self, request):
+        user = getattr(request, "user", None)
         if user is not None:
             request.user = SimpleLazyObject(
-                functools.partial(self._verify_user, request, user)
+                functools.partial(self._verify_user_sync, request, user)
             )
 
-        return self.get_response(request)
+        auser = getattr(request, "auser", None)
+        if auser is not None:
+            request.auser = functools.partial(
+                self._verify_user_async_via_auser, request, auser
+            )
 
-    def _verify_user(self, request, user):
-        """
-        Sets OTP-related fields on an authenticated user.
-        """
+    @staticmethod
+    def _init_user_fields(user):
         user.otp_device = None
         user.is_verified = functools.partial(is_verified, user)
 
+    @staticmethod
+    def _normalize_persistent_id(persistent_id: str) -> str:
+        # Convert legacy persistent_id values (these used to be full import
+        # paths). This won't work for apps with models in sub-modules, but that
+        # should be pretty rare. And the worst that happens is the user has to
+        # log in again.
+        if persistent_id.count(".") > 1:
+            parts = persistent_id.split(".")
+            return ".".join((parts[-3], parts[-1]))
+        return persistent_id
+
+    @staticmethod
+    def _finalize_device(request, user, device):
+        """
+        Enforce device-user binding and keep session state consistent.
+        """
+        if (device is not None) and (device.user_id != user.pk):
+            device = None
+
+        if (device is None) and (DEVICE_ID_SESSION_KEY in request.session):
+            del request.session[DEVICE_ID_SESSION_KEY]
+
+        return device
+
+    def _verify_user_sync(self, request, user):
+        self._init_user_fields(user)
+
         if user.is_authenticated:
             persistent_id = request.session.get(DEVICE_ID_SESSION_KEY)
             device = (
@@ -48,26 +99,29 @@
                 if persistent_id
                 else None
             )
+            user.otp_device = self._finalize_device(request, user, device)
 
-            if (device is not None) and (device.user_id != user.pk):
-                device = None
+        return user
 
-            if (device is None) and (DEVICE_ID_SESSION_KEY in request.session):
-                del request.session[DEVICE_ID_SESSION_KEY]
+    def _device_from_persistent_id(self, persistent_id: str):
+        persistent_id = self._normalize_persistent_id(persistent_id)
+        return Device.from_persistent_id(persistent_id)
+
+    async def _verify_user_async_via_auser(self, request, auser):
+        user = await auser()
+        self._init_user_fields(user)
 
-            user.otp_device = device
+        if user.is_authenticated:
+            persistent_id = request.session.get(DEVICE_ID_SESSION_KEY)
+            device = (
+                await self._adevice_from_persistent_id(persistent_id)
+                if persistent_id
+                else None
+            )
+            user.otp_device = self._finalize_device(request, user, device)
 
         return user
 
-    def _device_from_persistent_id(self, persistent_id):
-        # Convert legacy persistent_id values (these used to be full import
-        # paths). This won't work for apps with models in sub-modules, but that
-        # should be pretty rare. And the worst that happens is the user has to
-        # log in again.
-        if persistent_id.count('.') > 1:
-            parts = persistent_id.split('.')
-            persistent_id = '.'.join((parts[-3], parts[-1]))
-
-        device = Device.from_persistent_id(persistent_id)
-
-        return device
+    async def _adevice_from_persistent_id(self, persistent_id: str):
+        persistent_id = self._normalize_persistent_id(persistent_id)
+        return await Device.afrom_persistent_id(persistent_id)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django_otp-1.6.3/src/django_otp/models.py 
new/django_otp-1.7.0/src/django_otp/models.py
--- old/django_otp-1.6.3/src/django_otp/models.py       2020-02-02 
01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/src/django_otp/models.py       2020-02-02 
01:00:00.000000000 +0100
@@ -1,3 +1,4 @@
+from contextlib import suppress
 from datetime import timedelta
 import enum
 
@@ -133,22 +134,28 @@
             this must be called inside a transaction.
 
         """
-        device = None
+        with suppress(ValueError, LookupError):
+            return cls._filter_persistent_id(persistent_id, for_verify).first()
+        return None
 
-        try:
-            model_label, device_id = persistent_id.rsplit('/', 1)
-            app_label, model_name = model_label.split('.')
-
-            device_cls = apps.get_model(app_label, model_name)
-            if issubclass(device_cls, Device):
-                device_set = device_cls.objects.filter(id=int(device_id))
-                if for_verify:
-                    device_set = device_set.select_for_update()
-                device = device_set.first()
-        except (ValueError, LookupError):
-            pass
+    @classmethod
+    async def afrom_persistent_id(cls, persistent_id, for_verify=False):
+        with suppress(ValueError, LookupError):
+            return await cls._filter_persistent_id(persistent_id, 
for_verify).afirst()
+        return None
 
-        return device
+    @classmethod
+    def _filter_persistent_id(cls, persistent_id, for_verify=False):
+        model_label, device_id = persistent_id.rsplit("/", 1)
+        app_label, model_name = model_label.split(".")
+
+        device_cls = apps.get_model(app_label, model_name)
+        if issubclass(device_cls, Device):
+            device_set = device_cls.objects.filter(id=int(device_id))
+            if for_verify:
+                device_set = device_set.select_for_update()
+            return device_set
+        return None
 
     def is_interactive(self):
         """
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/django_otp-1.6.3/src/django_otp/plugins/otp_email/tests.py 
new/django_otp-1.7.0/src/django_otp/plugins/otp_email/tests.py
--- old/django_otp-1.6.3/src/django_otp/plugins/otp_email/tests.py      
2020-02-02 01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/src/django_otp/plugins/otp_email/tests.py      
2020-02-02 01:00:00.000000000 +0100
@@ -7,7 +7,7 @@
 from django.test.utils import override_settings
 
 from django_otp.forms import OTPAuthenticationForm
-from django_otp.tests import (
+from django_otp.test_utils import (
     CooldownTestMixin,
     TestCase,
     ThrottlingTestMixin,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/django_otp-1.6.3/src/django_otp/plugins/otp_hotp/tests.py 
new/django_otp-1.7.0/src/django_otp/plugins/otp_hotp/tests.py
--- old/django_otp-1.6.3/src/django_otp/plugins/otp_hotp/tests.py       
2020-02-02 01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/src/django_otp/plugins/otp_hotp/tests.py       
2020-02-02 01:00:00.000000000 +0100
@@ -13,7 +13,7 @@
 from django.urls import reverse
 
 from django_otp.forms import OTPAuthenticationForm
-from django_otp.tests import TestCase, ThrottlingTestMixin, TimestampTestMixin
+from django_otp.test_utils import TestCase, ThrottlingTestMixin, 
TimestampTestMixin
 
 from .admin import HOTPDeviceAdmin
 from .models import HOTPDevice
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/django_otp-1.6.3/src/django_otp/plugins/otp_static/tests.py 
new/django_otp-1.7.0/src/django_otp/plugins/otp_static/tests.py
--- old/django_otp-1.6.3/src/django_otp/plugins/otp_static/tests.py     
2020-02-02 01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/src/django_otp/plugins/otp_static/tests.py     
2020-02-02 01:00:00.000000000 +0100
@@ -6,7 +6,7 @@
 from django.test.utils import override_settings
 
 from django_otp.forms import OTPAuthenticationForm
-from django_otp.tests import TestCase, ThrottlingTestMixin, TimestampTestMixin
+from django_otp.test_utils import TestCase, ThrottlingTestMixin, 
TimestampTestMixin
 
 from .admin import StaticDeviceAdmin, StaticTokenInline
 from .lib import add_static_token
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/django_otp-1.6.3/src/django_otp/plugins/otp_totp/tests.py 
new/django_otp-1.7.0/src/django_otp/plugins/otp_totp/tests.py
--- old/django_otp-1.6.3/src/django_otp/plugins/otp_totp/tests.py       
2020-02-02 01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/src/django_otp/plugins/otp_totp/tests.py       
2020-02-02 01:00:00.000000000 +0100
@@ -10,7 +10,7 @@
 from django.test.utils import override_settings
 from django.urls import reverse
 
-from django_otp.tests import TestCase, ThrottlingTestMixin, TimestampTestMixin
+from django_otp.test_utils import TestCase, ThrottlingTestMixin, 
TimestampTestMixin
 
 from .admin import TOTPDeviceAdmin
 from .models import TOTPDevice
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django_otp-1.6.3/src/django_otp/test_utils.py 
new/django_otp-1.7.0/src/django_otp/test_utils.py
--- old/django_otp-1.6.3/src/django_otp/test_utils.py   1970-01-01 
01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/src/django_otp/test_utils.py   2020-02-02 
01:00:00.000000000 +0100
@@ -0,0 +1,315 @@
+from __future__ import annotations
+
+from datetime import timedelta
+
+from freezegun import freeze_time
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase as DjangoTestCase
+from django.test import TransactionTestCase as DjangoTransactionTestCase
+from django.utils import timezone
+
+from django_otp.models import GenerateNotAllowed, VerifyNotAllowed
+
+
+class OTPTestCaseMixin:
+    """
+    Utilities for dealing with custom user models.
+    """
+
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+
+        cls.User = get_user_model()
+        cls.USERNAME_FIELD = cls.User.USERNAME_FIELD
+
+    def create_user(self, username, password, **kwargs):
+        """
+        Try to create a user, honoring the custom user model, if any.
+
+        This may raise an exception if the user model is too exotic for our
+        purposes.
+        """
+        return self.User.objects.create_user(username, password=password, 
**kwargs)
+
+
+class TestCase(OTPTestCaseMixin, DjangoTestCase):
+    pass
+
+
+class TransactionTestCase(OTPTestCaseMixin, DjangoTransactionTestCase):
+    pass
+
+
+class TimestampTestMixin:
+    """
+    Generic tests for :class:`~django_otp.models.TimestampMixin`.
+
+    Implementing tests must initialize `self.device` with the model instance to
+    test and provide `valid_token` and `invalid_token` methods for verifying
+    token behavior.
+
+    Includes tests to:
+
+    - Check automatic setting of `created_at` upon object creation.
+    - Validate that `last_used_at` is initially None and updated only after
+      successful token verification.
+    - Ensure `set_last_used_timestamp` behaves correctly, respecting the
+      `commit` parameter.
+
+    """
+
+    def setUp(self):
+        self.device = None
+
+    def valid_token(self):
+        """Returns a valid token to pass to our device under test."""
+        raise NotImplementedError()
+
+    def invalid_token(self):
+        """Returns an invalid token to pass to our device under test."""
+        raise NotImplementedError()
+
+    #
+    # Tests
+    #
+
+    def test_created_at_set_on_creation(self):
+        """Verify that the `created_at` field is automatically set upon 
creation."""
+        self.assertIsNotNone(
+            self.device.created_at, "created_at should be automatically set."
+        )
+
+    def test_last_used_at_initially_none(self):
+        """Ensure `last_used_at` is None upon initial creation."""
+        self.assertIsNone(
+            self.device.last_used_at, "last_used_at should be None initially."
+        )
+
+    def test_set_last_used_timestamp_updates_field(self):
+        """Check if `set_last_used_timestamp` correctly updates the 
`last_used_at` field."""
+        self.device.set_last_used_timestamp(commit=True)
+        self.device.refresh_from_db()  # Assuming it's a persisted model
+
+        self.assertIsNotNone(
+            self.device.last_used_at, "last_used_at should be updated."
+        )
+
+    def test_set_last_used_timestamp_without_commit(self):
+        """
+        Ensure `set_last_used_timestamp` updates `last_used_at` without 
persisting
+        when commit=False.
+        """
+        original_last_used_at = self.device.last_used_at
+        self.device.set_last_used_timestamp(commit=False)
+        # Check in-memory update without saving
+        self.assertNotEqual(
+            self.device.last_used_at,
+            original_last_used_at,
+            "last_used_at should be updated in memory without commit.",
+        )
+
+        # Refresh from db to confirm it wasn't committed
+        self.device.refresh_from_db()
+        self.assertEqual(
+            self.device.last_used_at,
+            original_last_used_at,
+            "last_used_at should not be updated in db without commit.",
+        )
+
+    def test_verify_token_successful_updates_last_used_at(self):
+        """
+        Verifying with a valid token updates 'last_used_at'.
+        """
+        valid_token = self.valid_token()  # Method to generate a valid token
+        initial_last_used_at = self.device.last_used_at
+        verified = self.device.verify_token(valid_token)
+
+        self.assertTrue(verified, "Token should be verified successfully.")
+        self.device.refresh_from_db()
+        self.assertNotEqual(
+            self.device.last_used_at,
+            initial_last_used_at,
+            "'last_used_at' should be updated on successful verification.",
+        )
+
+    def test_verify_token_failed_does_not_update_last_used_at(self):
+        """
+        Verifying with an invalid token does not update 'last_used_at'.
+        """
+        invalid_token = self.invalid_token()  # Method to generate an invalid 
token
+        initial_last_used_at = self.device.last_used_at
+        verified = self.device.verify_token(invalid_token)
+
+        self.assertFalse(verified, "Token should not be verified.")
+        self.device.refresh_from_db()
+        self.assertEqual(
+            self.device.last_used_at,
+            initial_last_used_at,
+            "'last_used_at' should not be updated on failed verification.",
+        )
+
+
+class ThrottlingTestMixin:
+    """
+    Generic tests for throttled devices.
+
+    Any concrete device implementation that uses throttling should define a
+    TestCase subclass that includes this as a base class. This will help verify
+    a correct integration of ThrottlingMixin.
+
+    Subclasses are responsible for populating self.device with a device to test
+    as well as implementing methods to generate tokens to test with.
+
+    """
+
+    def setUp(self):
+        self.device = None
+
+    def valid_token(self):
+        """Returns a valid token to pass to our device under test."""
+        raise NotImplementedError()
+
+    def invalid_token(self):
+        """Returns an invalid token to pass to our device under test."""
+        raise NotImplementedError()
+
+    #
+    # Tests
+    #
+
+    def test_delay_imposed_after_fail(self):
+        verified1 = self.device.verify_token(self.invalid_token())
+        self.assertFalse(verified1)
+        verified2 = self.device.verify_token(self.valid_token())
+        self.assertFalse(verified2)
+
+    def test_delay_after_fail_expires(self):
+        verified1 = self.device.verify_token(self.invalid_token())
+        self.assertFalse(verified1)
+        with freeze_time() as frozen_time:
+            # With default settings initial delay is 1 second
+            frozen_time.tick(delta=timedelta(seconds=1.1))
+            verified2 = self.device.verify_token(self.valid_token())
+            self.assertTrue(verified2)
+
+    def test_throttling_failure_count(self):
+        self.assertEqual(self.device.throttling_failure_count, 0)
+        for _ in range(0, 5):
+            self.device.verify_token(self.invalid_token())
+            # Only the first attempt will increase throttling_failure_count,
+            # the others will all be within 1 second of first
+            # and therefore not count as attempts.
+            self.assertEqual(self.device.throttling_failure_count, 1)
+
+    def test_verify_is_allowed(self):
+        # Initially should be allowed
+        verify_is_allowed1, data1 = self.device.verify_is_allowed()
+        self.assertEqual(verify_is_allowed1, True)
+        self.assertEqual(data1, None)
+
+        # After failure, verify is not allowed
+        with freeze_time():
+            self.device.verify_token(self.invalid_token())
+            verify_is_allowed2, data2 = self.device.verify_is_allowed()
+            self.assertEqual(verify_is_allowed2, False)
+            self.assertEqual(
+                data2,
+                {
+                    'reason': VerifyNotAllowed.N_FAILED_ATTEMPTS,
+                    'failure_count': 1,
+                    'locked_until': timezone.now() + 
timezone.timedelta(seconds=1),
+                },
+            )
+
+        # After a successful attempt, should be allowed again
+        with freeze_time() as frozen_time:
+            frozen_time.tick(delta=timedelta(seconds=1.1))
+            self.device.verify_token(self.valid_token())
+
+            verify_is_allowed3, data3 = self.device.verify_is_allowed()
+            self.assertEqual(verify_is_allowed3, True)
+            self.assertEqual(data3, None)
+
+
+class CooldownTestMixin:
+    def setUp(self):
+        self.device = None
+
+    def valid_token(self):
+        """Returns a valid token to pass to our device under test."""
+        raise NotImplementedError()
+
+    def invalid_token(self):
+        """Returns an invalid token to pass to our device under test."""
+        raise NotImplementedError()
+
+    #
+    # Tests
+    #
+
+    def test_generate_is_allowed_on_first_try(self):
+        """Token generation should be allowed on first try."""
+        allowed, _ = self.device.generate_is_allowed()
+        self.assertTrue(allowed)
+
+    def test_cooldown_imposed_after_successful_generation(self):
+        """
+        Token generation before cooldown should not be allowed
+        and the relevant reason should be returned.
+        """
+        with freeze_time():
+            self.device.generate_challenge()
+            self.device.refresh_from_db()
+            allowed, details = self.device.generate_is_allowed()
+
+            self.assertFalse(allowed)
+            self.assertEqual(
+                details['reason'], GenerateNotAllowed.COOLDOWN_DURATION_PENDING
+            )
+
+    def test_cooldown_expire_time(self):
+        """
+        When token generation is not allowed, the cooldown expire time
+        should be returned.
+        """
+        with freeze_time():
+            self.device.generate_challenge()
+            self.device.refresh_from_db()
+            _, details = self.device.generate_is_allowed()
+            self.assertEqual(
+                details['next_generation_at'], timezone.now() + 
timedelta(seconds=10)
+            )
+
+    def test_cooldown_reset(self):
+        """Cooldown can be reset and allow token generation again before the 
initial period expires."""
+        with freeze_time():
+            self.device.generate_is_allowed()
+            self.device.refresh_from_db()
+            self.device.cooldown_reset()
+            self.device.refresh_from_db()
+            allowed, _ = self.device.generate_is_allowed()
+            self.assertTrue(allowed)
+
+    def test_valid_token_verification_resets_cooldown(self):
+        """When the token is verified, the cooldown period is reset."""
+        with freeze_time():
+            self.device.generate_challenge()
+            self.device.refresh_from_db()
+            verified = self.device.verify_token(self.valid_token())
+            self.assertTrue(verified)
+            self.device.refresh_from_db()
+            allowed, _ = self.device.generate_is_allowed()
+            self.assertTrue(allowed)
+
+    def test_invalid_token_verification_does_not_reset_cooldown(self):
+        """When the token is not verified, the cooldown period is not reset."""
+        with freeze_time():
+            self.device.generate_challenge()
+            self.device.refresh_from_db()
+            verified = self.device.verify_token(self.invalid_token())
+            self.assertFalse(verified)
+            self.device.refresh_from_db()
+            allowed, _ = self.device.generate_is_allowed()
+            self.assertFalse(allowed)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django_otp-1.6.3/src/django_otp/tests.py 
new/django_otp-1.7.0/src/django_otp/tests.py
--- old/django_otp-1.6.3/src/django_otp/tests.py        2020-02-02 
01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/src/django_otp/tests.py        2020-02-02 
01:00:00.000000000 +0100
@@ -1,24 +1,16 @@
-from datetime import timedelta
 from doctest import DocTestSuite
 from io import StringIO
 import pickle
 from threading import Thread
 import unittest
 
-from freezegun import freeze_time
-
-from django.contrib.auth import get_user_model
 from django.contrib.auth.models import AnonymousUser
 from django.core.management import call_command
 from django.core.management.base import CommandError
 from django.db import IntegrityError, connection
-from django.test import RequestFactory
-from django.test import TestCase as DjangoTestCase
-from django.test import TransactionTestCase as DjangoTransactionTestCase
-from django.test import skipUnlessDBFeature
+from django.test import AsyncRequestFactory, RequestFactory, 
skipUnlessDBFeature
 from django.test.utils import override_settings
 from django.urls import reverse
-from django.utils import timezone
 
 from django_otp import (
     DEVICE_ID_SESSION_KEY,
@@ -31,9 +23,10 @@
 )
 from django_otp.forms import OTPTokenForm, otp_verification_failed
 from django_otp.middleware import OTPMiddleware
-from django_otp.models import GenerateNotAllowed, VerifyNotAllowed
 from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
 
+from .test_utils import TestCase, TransactionTestCase
+
 
 def load_tests(loader, tests, pattern):
     suite = unittest.TestSuite()
@@ -53,309 +46,6 @@
         connection.close()
 
 
-class OTPTestCaseMixin:
-    """
-    Utilities for dealing with custom user models.
-    """
-
-    @classmethod
-    def setUpClass(cls):
-        super().setUpClass()
-
-        cls.User = get_user_model()
-        cls.USERNAME_FIELD = cls.User.USERNAME_FIELD
-
-    def create_user(self, username, password, **kwargs):
-        """
-        Try to create a user, honoring the custom user model, if any.
-
-        This may raise an exception if the user model is too exotic for our
-        purposes.
-        """
-        return self.User.objects.create_user(username, password=password, 
**kwargs)
-
-
-class TestCase(OTPTestCaseMixin, DjangoTestCase):
-    pass
-
-
-class TransactionTestCase(OTPTestCaseMixin, DjangoTransactionTestCase):
-    pass
-
-
-class TimestampTestMixin:
-    """
-    Generic tests for :class:`~django_otp.models.TimestampMixin`.
-
-    Implementing tests must initialize `self.device` with the model instance to
-    test and provide `valid_token` and `invalid_token` methods for verifying
-    token behavior.
-
-    Includes tests to:
-
-    - Check automatic setting of `created_at` upon object creation.
-    - Validate that `last_used_at` is initially None and updated only after
-      successful token verification.
-    - Ensure `set_last_used_timestamp` behaves correctly, respecting the
-      `commit` parameter.
-
-    """
-
-    def setUp(self):
-        self.device = None
-
-    def valid_token(self):
-        """Returns a valid token to pass to our device under test."""
-        raise NotImplementedError()
-
-    def invalid_token(self):
-        """Returns an invalid token to pass to our device under test."""
-        raise NotImplementedError()
-
-    #
-    # Tests
-    #
-
-    def test_created_at_set_on_creation(self):
-        """Verify that the `created_at` field is automatically set upon 
creation."""
-        self.assertIsNotNone(
-            self.device.created_at, "created_at should be automatically set."
-        )
-
-    def test_last_used_at_initially_none(self):
-        """Ensure `last_used_at` is None upon initial creation."""
-        self.assertIsNone(
-            self.device.last_used_at, "last_used_at should be None initially."
-        )
-
-    def test_set_last_used_timestamp_updates_field(self):
-        """Check if `set_last_used_timestamp` correctly updates the 
`last_used_at` field."""
-        self.device.set_last_used_timestamp(commit=True)
-        self.device.refresh_from_db()  # Assuming it's a persisted model
-
-        self.assertIsNotNone(
-            self.device.last_used_at, "last_used_at should be updated."
-        )
-
-    def test_set_last_used_timestamp_without_commit(self):
-        """
-        Ensure `set_last_used_timestamp` updates `last_used_at` without 
persisting
-        when commit=False.
-        """
-        original_last_used_at = self.device.last_used_at
-        self.device.set_last_used_timestamp(commit=False)
-        # Check in-memory update without saving
-        self.assertNotEqual(
-            self.device.last_used_at,
-            original_last_used_at,
-            "last_used_at should be updated in memory without commit.",
-        )
-
-        # Refresh from db to confirm it wasn't committed
-        self.device.refresh_from_db()
-        self.assertEqual(
-            self.device.last_used_at,
-            original_last_used_at,
-            "last_used_at should not be updated in db without commit.",
-        )
-
-    def test_verify_token_successful_updates_last_used_at(self):
-        """
-        Verifying with a valid token updates 'last_used_at'.
-        """
-        valid_token = self.valid_token()  # Method to generate a valid token
-        initial_last_used_at = self.device.last_used_at
-        verified = self.device.verify_token(valid_token)
-
-        self.assertTrue(verified, "Token should be verified successfully.")
-        self.device.refresh_from_db()
-        self.assertNotEqual(
-            self.device.last_used_at,
-            initial_last_used_at,
-            "'last_used_at' should be updated on successful verification.",
-        )
-
-    def test_verify_token_failed_does_not_update_last_used_at(self):
-        """
-        Verifying with an invalid token does not update 'last_used_at'.
-        """
-        invalid_token = self.invalid_token()  # Method to generate an invalid 
token
-        initial_last_used_at = self.device.last_used_at
-        verified = self.device.verify_token(invalid_token)
-
-        self.assertFalse(verified, "Token should not be verified.")
-        self.device.refresh_from_db()
-        self.assertEqual(
-            self.device.last_used_at,
-            initial_last_used_at,
-            "'last_used_at' should not be updated on failed verification.",
-        )
-
-
-class ThrottlingTestMixin:
-    """
-    Generic tests for throttled devices.
-
-    Any concrete device implementation that uses throttling should define a
-    TestCase subclass that includes this as a base class. This will help verify
-    a correct integration of ThrottlingMixin.
-
-    Subclasses are responsible for populating self.device with a device to test
-    as well as implementing methods to generate tokens to test with.
-
-    """
-
-    def setUp(self):
-        self.device = None
-
-    def valid_token(self):
-        """Returns a valid token to pass to our device under test."""
-        raise NotImplementedError()
-
-    def invalid_token(self):
-        """Returns an invalid token to pass to our device under test."""
-        raise NotImplementedError()
-
-    #
-    # Tests
-    #
-
-    def test_delay_imposed_after_fail(self):
-        verified1 = self.device.verify_token(self.invalid_token())
-        self.assertFalse(verified1)
-        verified2 = self.device.verify_token(self.valid_token())
-        self.assertFalse(verified2)
-
-    def test_delay_after_fail_expires(self):
-        verified1 = self.device.verify_token(self.invalid_token())
-        self.assertFalse(verified1)
-        with freeze_time() as frozen_time:
-            # With default settings initial delay is 1 second
-            frozen_time.tick(delta=timedelta(seconds=1.1))
-            verified2 = self.device.verify_token(self.valid_token())
-            self.assertTrue(verified2)
-
-    def test_throttling_failure_count(self):
-        self.assertEqual(self.device.throttling_failure_count, 0)
-        for i in range(0, 5):
-            self.device.verify_token(self.invalid_token())
-            # Only the first attempt will increase throttling_failure_count,
-            # the others will all be within 1 second of first
-            # and therefore not count as attempts.
-            self.assertEqual(self.device.throttling_failure_count, 1)
-
-    def test_verify_is_allowed(self):
-        # Initially should be allowed
-        verify_is_allowed1, data1 = self.device.verify_is_allowed()
-        self.assertEqual(verify_is_allowed1, True)
-        self.assertEqual(data1, None)
-
-        # After failure, verify is not allowed
-        with freeze_time():
-            self.device.verify_token(self.invalid_token())
-            verify_is_allowed2, data2 = self.device.verify_is_allowed()
-            self.assertEqual(verify_is_allowed2, False)
-            self.assertEqual(
-                data2,
-                {
-                    'reason': VerifyNotAllowed.N_FAILED_ATTEMPTS,
-                    'failure_count': 1,
-                    'locked_until': timezone.now() + 
timezone.timedelta(seconds=1),
-                },
-            )
-
-        # After a successful attempt, should be allowed again
-        with freeze_time() as frozen_time:
-            frozen_time.tick(delta=timedelta(seconds=1.1))
-            self.device.verify_token(self.valid_token())
-
-            verify_is_allowed3, data3 = self.device.verify_is_allowed()
-            self.assertEqual(verify_is_allowed3, True)
-            self.assertEqual(data3, None)
-
-
-class CooldownTestMixin:
-    def setUp(self):
-        self.device = None
-
-    def valid_token(self):
-        """Returns a valid token to pass to our device under test."""
-        raise NotImplementedError()
-
-    def invalid_token(self):
-        """Returns an invalid token to pass to our device under test."""
-        raise NotImplementedError()
-
-    #
-    # Tests
-    #
-
-    def test_generate_is_allowed_on_first_try(self):
-        """Token generation should be allowed on first try."""
-        allowed, _ = self.device.generate_is_allowed()
-        self.assertTrue(allowed)
-
-    def test_cooldown_imposed_after_successful_generation(self):
-        """
-        Token generation before cooldown should not be allowed
-        and the relevant reason should be returned.
-        """
-        with freeze_time():
-            self.device.generate_challenge()
-            self.device.refresh_from_db()
-            allowed, details = self.device.generate_is_allowed()
-
-            self.assertFalse(allowed)
-            self.assertEqual(
-                details['reason'], GenerateNotAllowed.COOLDOWN_DURATION_PENDING
-            )
-
-    def test_cooldown_expire_time(self):
-        """
-        When token generation is not allowed, the cooldown expire time
-        should be returned.
-        """
-        with freeze_time():
-            self.device.generate_challenge()
-            self.device.refresh_from_db()
-            _, details = self.device.generate_is_allowed()
-            self.assertEqual(
-                details['next_generation_at'], timezone.now() + 
timedelta(seconds=10)
-            )
-
-    def test_cooldown_reset(self):
-        """Cooldown can be reset and allow token generation again before the 
initial period expires."""
-        with freeze_time():
-            self.device.generate_is_allowed()
-            self.device.refresh_from_db()
-            self.device.cooldown_reset()
-            self.device.refresh_from_db()
-            allowed, _ = self.device.generate_is_allowed()
-            self.assertTrue(allowed)
-
-    def test_valid_token_verification_resets_cooldown(self):
-        """When the token is verified, the cooldown period is reset."""
-        with freeze_time():
-            self.device.generate_challenge()
-            self.device.refresh_from_db()
-            verified = self.device.verify_token(self.valid_token())
-            self.assertTrue(verified)
-            self.device.refresh_from_db()
-            allowed, _ = self.device.generate_is_allowed()
-            self.assertTrue(allowed)
-
-    def test_invalid_token_verification_does_not_reset_cooldown(self):
-        """When the token is not verified, the cooldown period is not reset."""
-        with freeze_time():
-            self.device.generate_challenge()
-            self.device.refresh_from_db()
-            verified = self.device.verify_token(self.invalid_token())
-            self.assertFalse(verified)
-            self.device.refresh_from_db()
-            allowed, _ = self.device.generate_is_allowed()
-            self.assertFalse(allowed)
-
-
 @override_settings(OTP_STATIC_THROTTLE_FACTOR=0)
 class APITestCase(TestCase):
     def setUp(self):
@@ -521,6 +211,104 @@
         pickle.dumps(request.user)
 
 
+class OTPMiddlewareAsyncTestCase(TestCase):
+    def setUp(self):
+        self.factory = AsyncRequestFactory()
+
+        try:
+            self.alice = self.create_user("alice", "password")
+            self.bob = self.create_user("bob", "password")
+        except IntegrityError:
+            self.skipTest("Unable to create a test user.")
+        else:
+            for user in (self.alice, self.bob):
+                device = user.staticdevice_set.create()
+                device.token_set.create(token=user.get_username())
+
+        # Precompute anything that would otherwise hit the (sync) ORM inside 
async tests.
+        alice_device = self.alice.staticdevice_set.get()
+        bob_device = self.bob.staticdevice_set.get()
+
+        self.alice_device_pid = alice_device.persistent_id
+        self.bob_device_pid = bob_device.persistent_id
+        self.alice_device_legacy_pid = "{}.{}/{}".format(
+            alice_device.__module__, alice_device.__class__.__name__, 
alice_device.id
+        )
+
+        async def get_response(request):
+            return None
+
+        self.middleware = OTPMiddleware(get_response)
+
+    async def _run_middleware(self, user, session):
+        request = self.factory.get("/")
+        request.session = session
+        request.user = user
+
+        async def auser():
+            return user
+
+        request.auser = auser
+
+        await self.middleware(request)
+        return request
+
+    async def test_verified(self):
+        request = await self._run_middleware(
+            self.alice, {DEVICE_ID_SESSION_KEY: self.alice_device_pid}
+        )
+
+        user = await request.auser()
+        self.assertTrue(user.is_verified())
+
+    async def test_verified_legacy_device_id(self):
+        request = await self._run_middleware(
+            self.alice, {DEVICE_ID_SESSION_KEY: self.alice_device_legacy_pid}
+        )
+
+        user = await request.auser()
+        self.assertTrue(user.is_verified())
+
+    async def test_unverified(self):
+        request = await self._run_middleware(self.alice, {})
+
+        user = await request.auser()
+        self.assertFalse(user.is_verified())
+
+    async def test_no_device(self):
+        request = await self._run_middleware(
+            self.alice, {DEVICE_ID_SESSION_KEY: "otp_static.staticdevice/0"}
+        )
+
+        user = await request.auser()
+        self.assertFalse(user.is_verified())
+
+    async def test_no_model(self):
+        request = await self._run_middleware(
+            self.alice, {DEVICE_ID_SESSION_KEY: "otp_bogus.bogusdevice/0"}
+        )
+
+        user = await request.auser()
+        self.assertFalse(user.is_verified())
+
+    async def test_wrong_user(self):
+        request = await self._run_middleware(
+            self.alice, {DEVICE_ID_SESSION_KEY: self.bob_device_pid}
+        )
+
+        user = await request.auser()
+        self.assertFalse(user.is_verified())
+
+    async def test_pickling(self):
+        request = await self._run_middleware(
+            self.alice, {DEVICE_ID_SESSION_KEY: self.alice_device_pid}
+        )
+
+        user = await request.auser()
+        # Should not raise an exception.
+        pickle.dumps(user)
+
+
 class LoginViewTestCase(TestCase):
     def setUp(self):
         try:

Reply via email to